Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
},
"dependencies": {
"@jupyterlab/application": "^4.0.0",
"@jupyterlab/coreutils": "^6.0.0",
"@jupyterlab/statedb": "^4.0.0",
"@lumino/algorithm": "^2.0.0",
"@lumino/coreutils": "^2.1.2"
Expand Down
19 changes: 8 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ import {
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { SecretsManager } from './manager';
import { ISecretsConnector, ISecretsManager } from './token';
import { ISecretsManager } from './token';
import { InMemoryConnector } from './connectors';

/**
* A basic secret connector extension, that should be disabled to provide a new
* connector.
*/
const inMemoryConnector: JupyterFrontEndPlugin<ISecretsConnector> = {
const inMemoryConnector: JupyterFrontEndPlugin<void> = {
id: 'jupyter-secrets-manager:connector',
description: 'A JupyterLab extension to manage secrets.',
autoStart: true,
provides: ISecretsConnector,
activate: (app: JupyterFrontEnd): ISecretsConnector => {
return new InMemoryConnector();
requires: [ISecretsManager],
activate: (app: JupyterFrontEnd, manager: ISecretsManager): void => {
manager.setConnector(new InMemoryConnector());
}
};

Expand All @@ -28,16 +28,13 @@ const manager: JupyterFrontEndPlugin<ISecretsManager> = {
description: 'A JupyterLab extension to manage secrets.',
autoStart: true,
provides: ISecretsManager,
requires: [ISecretsConnector],
activate: (
app: JupyterFrontEnd,
connector: ISecretsConnector
): ISecretsManager => {
activate: (app: JupyterFrontEnd): ISecretsManager => {
console.log('JupyterLab extension jupyter-secrets-manager is activated!');
return new SecretsManager({ connector });
return new SecretsManager();
}
};

export * from './connectors';
export * from './manager';
export * from './token';
export default [inMemoryConnector, manager];
203 changes: 166 additions & 37 deletions src/manager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
import { PageConfig } from '@jupyterlab/coreutils';
import { PromiseDelegate } from '@lumino/coreutils';

import {
Expand All @@ -8,64 +10,82 @@ import {
} from './token';

/**
* The secrets manager namespace.
*/
export namespace SecretsManager {
/**
* Secrets manager constructor's options.
*/
export interface IOptions {
connector: ISecretsConnector;
}
}

/**
* The default secrets manager implementation.
* The default secrets manager.
*/
export class SecretsManager implements ISecretsManager {
/**
* the secrets manager constructor.
*/
constructor(options: SecretsManager.IOptions) {
this._connector = options.connector;
constructor() {
this._ready = new PromiseDelegate<void>();
this._ready.resolve();
}

/**
* Set the connector to use with the manager.
*
* NOTE:
* If several extensions try to set the connector, the manager will be locked.
* This is to prevent malicious extensions to get passwords when they are saved.
*/
setConnector(value: ISecretsConnector): void {
if (Private.getConnector() !== null) {
Private.lock('A connector was already provided to the secrets manager');
}
Private.setConnector(value);
}

get ready(): Promise<void> {
return this._ready.promise;
}

/**
* Get a secret given its namespace and ID.
*/
async get(namespace: string, id: string): Promise<ISecret | undefined> {
return this._get(Private.buildSecretId(namespace, id));
async get(
token: symbol,
namespace: string,
id: string
): Promise<ISecret | undefined> {
this._check(token, namespace);
return this._get(Private.buildConnectorId(namespace, id));
}

/**
* Set a secret given its namespace and ID.
*/
async set(namespace: string, id: string, secret: ISecret): Promise<any> {
return this._set(Private.buildSecretId(namespace, id), secret);
async set(
token: symbol,
namespace: string,
id: string,
secret: ISecret
): Promise<any> {
this._check(token, namespace);
return this._set(Private.buildConnectorId(namespace, id), secret);
}

/**
* List the secrets for a namespace as a ISecretsList.
*/
async list(namespace: string): Promise<ISecretsList | undefined> {
if (!this._connector.list) {
async list(
token: symbol,
namespace: string
): Promise<ISecretsList | undefined> {
this._check(token, namespace);
const connector = Private.getConnector();
if (!connector?.list) {
return;
}
await this._ready.promise;
return await this._connector.list(namespace);
return await connector.list(namespace);
}

/**
* Remove a secret given its namespace and ID.
*/
async remove(namespace: string, id: string): Promise<void> {
return this._remove(Private.buildSecretId(namespace, id));
async remove(token: symbol, namespace: string, id: string): Promise<void> {
this._check(token, namespace);
return this._remove(Private.buildConnectorId(namespace, id));
}

/**
Expand All @@ -74,17 +94,19 @@ export class SecretsManager implements ISecretsManager {
* is programmatically filled.
*/
async attach(
token: symbol,
namespace: string,
id: string,
input: HTMLInputElement,
callback?: (value: string) => void
): Promise<void> {
const attachedId = Private.buildSecretId(namespace, id);
this._check(token, namespace);
const attachedId = Private.buildConnectorId(namespace, id);
const attachedInput = this._attachedInputs.get(attachedId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_attachedInputs should be in the private namespace, because i could do this in my extension

secretManager['_attachedInputs'].forEach(it => {
   it.addeventlistener(...)
})

and get the value of the input when the secret value is filled.

Copy link
Collaborator Author

@brichet brichet Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I realized that as well, but your extension can also do

document.querySelector('[data-namespace]')

And I don't think that we can do anything about that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't have to save namespace and secret id to the input, just reverse the _attachedInputs map is enough to find these value from input element

Copy link
Collaborator Author

@brichet brichet Apr 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree that it would still be better to not expose it, but the extension can still do

document.querySelectorAll('input[type=password]')

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, at this point we can't do anything


// Detach the previous input.
if (attachedInput) {
this.detach(namespace, id);
this.detach(token, namespace, id);
}
this._attachedInputs.set(attachedId, input);

Expand All @@ -108,14 +130,16 @@ export class SecretsManager implements ISecretsManager {
/**
* Detach the input previously attached with its namespace and ID.
*/
detach(namespace: string, id: string): void {
this._detach(Private.buildSecretId(namespace, id));
detach(token: symbol, namespace: string, id: string): void {
this._check(token, namespace);
this._detach(Private.buildConnectorId(namespace, id));
}

/**
* Detach all attached input for a namespace.
*/
async detachAll(namespace: string): Promise<void> {
async detachAll(token: symbol, namespace: string): Promise<void> {
this._check(token, namespace);
for (const id of this._attachedInputs.keys()) {
if (id.startsWith(`${namespace}:`)) {
this._detach(id);
Expand All @@ -127,31 +151,34 @@ export class SecretsManager implements ISecretsManager {
* Actually fetch the secret from the connector.
*/
private async _get(id: string): Promise<ISecret | undefined> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also worried about this kind of method, that doesn't check the token.
This method is still available in javascript, even if private in typescript context.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can move it into the Private namespace?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I can make them static and move them to the Private namespace.

if (!this._connector.fetch) {
const connector = Private.getConnector();
if (!connector?.fetch) {
return;
}
await this._ready.promise;
return this._connector.fetch(id);
return connector.fetch(id);
}

/**
* Actually save the secret using the connector.
*/
private async _set(id: string, secret: ISecret): Promise<any> {
if (!this._connector.save) {
const connector = Private.getConnector();
if (!connector?.save) {
return;
}
return this._connector.save(id, secret);
return connector.save(id, secret);
}

/**
* Actually remove the secrets using the connector.
*/
async _remove(id: string): Promise<void> {
if (!this._connector.remove) {
const connector = Private.getConnector();
if (!connector?.remove) {
return;
}
this._connector.remove(id);
connector.remove(id);
}

private _onInput = async (e: Event): Promise<void> => {
Expand Down Expand Up @@ -184,16 +211,118 @@ export class SecretsManager implements ISecretsManager {
this._attachedInputs.delete(attachedId);
}

private _connector: ISecretsConnector;
private _check(token: symbol, namespace: string): void {
if (Private.isLocked() || Private.namespace.get(token) !== namespace) {
throw new Error(
`The secrets namespace ${namespace} is not available with the provided token`
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new Error(
`The secrets namespace ${namespace} is not available with the provided token`
);
Private.lock(
`The secrets namespace ${namespace} is not available with the provided token`
);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know if we want to lock the manager on any failing attempt. It should not occurs, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly about this, but the only times I expect this to happen are in development so I erred on the side of security. I'm happy to defer to your judgement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use a strict flag that can be set when starting jupyterlab.

}
}

private _attachedInputs = new Map<string, HTMLInputElement>();
private _ready: PromiseDelegate<void>;
}

/**
* The secrets manager namespace.
*/
export namespace SecretsManager {
/**
* A function that protect the secrets namespaces from other plugins.
*
* @param id - the secrets namespace, which must match the plugin ID to prevent an
* extension to use an other extension namespace.
* @param factory - a plugin factory, taking a symbol as argument and returning a
* plugin.
* @returns - the plugin to activate.
*/
export function sign<T>(
id: string,
factory: ISecretsManager.PluginFactory<T>
): JupyterFrontEndPlugin<T> {
const { lock, isLocked, namespace: plugins, symbols } = Private;
const { isDisabled } = PageConfig.Extension;
if (isLocked()) {
throw new Error('Secret registry is locked, check errors.');
}
if (isDisabled('jupyter-secrets-manager:manager')) {
lock('Secret registry is disabled.');
}
if (isDisabled(id)) {
lock(`Sign error: plugin ${id} is disabled.`);
}
if (symbols.has(id)) {
lock(`Sign error: another plugin signed as "${id}".`);
}
const token = Symbol(id);
const plugin = factory(token);
if (id !== plugin.id) {
lock(`Sign error: plugin ID mismatch "${plugin.id}"≠"${id}".`);
}
plugins.set(token, id);
symbols.set(id, token);
return plugin;
}
}

namespace Private {
/**
* Internal 'locked' status.
*/
let locked: boolean = false;

/**
* The namespace associated to a symbol.
*/
export const namespace = new Map<symbol, string>();

/**
* The symbol associated to a namespace.
*/
export const symbols = new Map<string, symbol>();

/**
* Lock the manager.
*
* @param message - the error message to throw.
*/
export function lock(message: string) {
locked = true;
throw new Error(message);
}

/**
* Check if the manager is locked.
*
* @returns - whether the manager is locked or not.
*/
export function isLocked(): boolean {
return locked;
}

/**
* Connector used by the manager.
*/
let connector: ISecretsConnector | null = null;

/**
* Set the connector.
*/
export function setConnector(value: ISecretsConnector) {
connector = value;
}

/**
* Get the connector.
*/
export function getConnector(): ISecretsConnector | null {
return connector;
}

/**
* Build the secret id from the namespace and id.
*/
export function buildSecretId(namespace: string, id: string): string {
export function buildConnectorId(namespace: string, id: string): string {
return `${namespace}:${id}`;
}
}
Loading
Loading