Skip to content

Commit

Permalink
Command to verify PAT (#624)
Browse files Browse the repository at this point in the history
* enh: add cli command and supporting endpoint to verify token
  • Loading branch information
anibalsolon authored Nov 30, 2022
1 parent c73f5eb commit cea8434
Show file tree
Hide file tree
Showing 10 changed files with 202 additions and 3 deletions.
9 changes: 9 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"redhat.java",
"vscjava.vscode-java-debug",
"vscjava.vscode-java-test",
"richardwillis.vscode-gradle"
]
}
1 change: 1 addition & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export * from './create-namespace';
export * from './get';
export * from './publish';
export * from './registry';
export * from './verify-pat';
export { isLicenseOk } from './check-license';
export { validateManifest, readManifest } from './util';
9 changes: 9 additions & 0 deletions cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import * as commander from 'commander';
import * as leven from 'leven';
import { createNamespace } from './create-namespace';
import { verifyPat } from './verify-pat';
import { publish } from './publish';
import { handleError } from './util';
import { getExtension } from './get';
Expand All @@ -33,6 +34,14 @@ module.exports = function (argv: string[]): void {
.catch(handleError(program.debug));
});

const verifyTokenCmd = program.command('verify-pat [namespace]');
verifyTokenCmd.description('Verify that a personal access token can publish to a namespace')
.action((namespace?: string) => {
const { registryUrl, pat } = program.opts();
verifyPat({ namespace, registryUrl, pat })
.catch(handleError(program.debug));
});

const publishCmd = program.command('publish [extension.vsix]');
publishCmd.description('Publish an extension, packaging it first if necessary.')
.option('-t, --target <targets...>', 'Target architectures')
Expand Down
9 changes: 9 additions & 0 deletions cli/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export class Registry {
}
}

verifyPat(namespace: string, pat: string): Promise<Response> {
try {
const query: { [key: string]: string } = { token: pat };
return this.getJson(this.getUrl(`api/${namespace}/verify-pat`, query));
} catch (err) {
return Promise.reject(err);
}
}

publish(file: string, pat: string): Promise<Extension> {
try {
const query: { [key: string]: string } = { token: pat };
Expand Down
52 changes: 52 additions & 0 deletions cli/src/verify-pat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/********************************************************************************
* Copyright (c) 2022 Anibal Solon and others
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
********************************************************************************/

import { Registry, RegistryOptions } from './registry';
import { readManifest, addEnvOptions } from './util';

/**
* Validates that a Personal Access Token can publish to a namespace.
*/
export async function verifyPat(options: VerifyPatOptions): Promise<void> {
addEnvOptions(options);
if (!options.pat) {
throw new Error("A personal access token must be given with the option '--pat'.");
}

if (!options.namespace) {
let error;
try {
options.namespace = (await readManifest()).publisher;
} catch (e) {
error = e;
}

if (!options.namespace) {
throw new Error(
`Unable to read the namespace's name. Please supply it as an argument or run ovsx from the extension folder.` +
(error ? `\n\n${error}` : '')
);
}
}

const registry = new Registry(options);
const result = await registry.verifyPat(options.namespace, options.pat);
if (result.error) {
throw new Error(result.error);
}
console.log(`\ud83d\ude80 PAT valid to publish at ${options.namespace}`);
}

export interface VerifyPatOptions extends RegistryOptions {
/**
* Name of the namespace.
*/
namespace?: string
}
19 changes: 19 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/LocalRegistryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,25 @@ public ResultJson createNamespace(NamespaceJson json, UserData user) {
return ResultJson.success("Created namespace " + namespace.getName());
}

public ResultJson verifyToken(String namespaceName, String tokenValue) {
var token = users.useAccessToken(tokenValue);
if (token == null) {
throw new ErrorResultException("Invalid access token.");
}

var namespace = repositories.findNamespace(namespaceName);
if (namespace == null) {
throw new NotFoundException();
}

var user = token.getUser();
if (!users.hasPublishPermission(user, namespace)) {
throw new ErrorResultException("Insufficient access rights for namespace: " + namespaceName);
}

return ResultJson.success("Valid token");
}

@Retryable(value = { DataIntegrityViolationException.class })
@Transactional(rollbackOn = ErrorResultException.class)
public ExtensionJson publish(InputStream content, UserData user) throws ErrorResultException {
Expand Down
54 changes: 54 additions & 0 deletions server/src/main/java/org/eclipse/openvsx/RegistryAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,60 @@ public ResponseEntity<NamespaceJson> getNamespace(
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
}

@GetMapping(
path = "/api/{namespace}/verify-pat",
produces = MediaType.APPLICATION_JSON_VALUE
)
@CrossOrigin
@Operation(summary = "Check if a personal access token is valid and is allowed to publish in a namespace")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "The provided PAT is valid and is allowed to publish extensions in the namespace"
),
@ApiResponse(
responseCode = "400",
description = "The token has no publishing permission in the namespace or is not valid",
content = @Content()
),
@ApiResponse(
responseCode = "404",
description = "The specified namespace could not be found",
content = @Content()
),
@ApiResponse(
responseCode = "429",
description = "A client has sent too many requests in a given amount of time",
content = @Content(),
headers = {
@Header(
name = "X-Rate-Limit-Retry-After-Seconds",
description = "Number of seconds to wait after receiving a 429 response",
schema = @Schema(type = "integer", format = "int32")
),
@Header(
name = "X-Rate-Limit-Remaining",
description = "Remaining number of requests left",
schema = @Schema(type = "integer", format = "int32")
)
}
)
})
public ResponseEntity<ResultJson> verifyToken(
@PathVariable @Parameter(description = "Namespace", example = "redhat")
String namespace,
@RequestParam @Parameter(description = "A personal access token") String token
) {
try {
return ResponseEntity.ok(local.verifyToken(namespace, token));
} catch (NotFoundException exc) {
var json = ResultJson.error("Namespace not found: " + namespace);
return new ResponseEntity<>(json, HttpStatus.NOT_FOUND);
} catch (ErrorResultException exc) {
return exc.toResponseEntity(ResultJson.class);
}
}

@GetMapping(
path = "/api/{namespace}/{extension}",
produces = MediaType.APPLICATION_JSON_VALUE
Expand Down
3 changes: 2 additions & 1 deletion server/src/main/java/org/eclipse/openvsx/json/BadgeJson.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

import io.swagger.v3.oas.annotations.media.Schema;;import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.Objects;

@Schema(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;

import io.swagger.v3.oas.annotations.media.Schema;;import java.io.Serializable;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.Objects;

@Schema(
Expand Down
46 changes: 45 additions & 1 deletion server/src/test/java/org/eclipse/openvsx/RegistryAPITest.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@
import org.eclipse.openvsx.storage.*;
import org.eclipse.openvsx.util.TargetPlatform;
import org.eclipse.openvsx.util.VersionService;
import org.jobrunr.scheduling.JobRequestScheduler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient;
Expand Down Expand Up @@ -828,6 +829,49 @@ public void testCreateExistingNamespace() throws Exception {
.andExpect(status().isBadRequest())
.andExpect(content().json(errorJson("Namespace already exists: foobar")));
}

@ParameterizedTest
@ValueSource(strings = {"owner", "contributor", "sole-contributor"})
public void testVerifyToken(String mode) throws Exception {
mockForPublish(mode);

mockMvc.perform(get("/api/{namespace}/verify-pat?token={token}", "foo", "my_token"))
.andExpect(status().isOk());
}

@Test
public void testVerifyTokenNoNamespace() throws Exception {
mockAccessToken();

mockMvc.perform(get("/api/{namespace}/verify-pat?token={token}", "unexistingnamespace", "my_token"))
.andExpect(status().isNotFound());
}

@Test
public void testVerifyTokenInvalid() throws Exception {
mockForPublish("invalid");

mockMvc.perform(get("/api/{namespace}/verify-pat?token={token}", "foo", "my_token"))
.andExpect(status().isBadRequest());
}

@Test
public void testVerifyTokenNoToken() throws Exception {
mockAccessToken();
mockNamespace();

mockMvc.perform(get("/api/{namespace}/verify-pat", "foobar"))
.andExpect(status().isBadRequest());
}

@Test
public void testVerifyTokenNoPermission() throws Exception {
mockAccessToken();
mockNamespace();

mockMvc.perform(get("/api/{namespace}/verify-pat?token={token}", "foobar", "my_token"))
.andExpect(status().isBadRequest());
}

@Test
public void testPublishOrphan() throws Exception {
Expand Down

0 comments on commit cea8434

Please sign in to comment.