Add hosted mobile token revoke helper

This commit is contained in:
rcourtman 2026-04-02 23:26:13 +01:00
parent 57cc212f34
commit ce4e418d84
5 changed files with 380 additions and 70 deletions

View file

@ -7,11 +7,9 @@ import path from 'node:path';
import process from 'node:process';
import {
resolveHostedTenantRootDataDir,
restartHostedTenantRuntime,
runRemote,
shellQuote,
} from './hosted-tenant-runtime.mjs';
import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
const DEFAULT_CONTROL_PLANE_URL = 'https://cloud.pulserelay.pro';
@ -106,28 +104,6 @@ function runText(command, args, options = {}) {
});
}
function buildLocalHelper(tempDir) {
const binaryPath = path.join(tempDir, 'relay-mobile-token-helper');
execFileSync('go', [
'build',
'-buildvcs=false',
'-o',
binaryPath,
'./tests/integration/scripts/relay-mobile-token-helper.go',
], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: {
...process.env,
CGO_ENABLED: '0',
GOARCH: 'amd64',
GOOS: 'linux',
},
stdio: 'pipe',
});
return binaryPath;
}
function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
const parsed = new URL(controlPlaneUrl);
parsed.hostname = `${tenantId}.${parsed.hostname}`;
@ -137,36 +113,6 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
return parsed.toString().replace(/\/$/, '');
}
function createHostedRelayMobileToken({ cloudHost, tenantId, tempDir }) {
const localBinaryPath = buildLocalHelper(tempDir);
const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`;
const tenantDataDir = resolveHostedTenantRootDataDir(tenantId);
try {
execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
stdio: 'pipe',
});
runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`);
const output = runRemote(cloudHost, [
shellQuote(remoteBinaryPath),
'create',
'--data-dir',
shellQuote(tenantDataDir),
'--org-id',
shellQuote(tenantId),
].join(' '));
return JSON.parse(output);
} finally {
try {
runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`);
} catch {}
}
}
function curlJson(args) {
return JSON.parse(runText('curl', args));
}

View file

@ -0,0 +1,95 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { deleteHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs';
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
function usage(message) {
if (message) {
console.error(`error: ${message}`);
console.error('');
}
console.error(
'usage: node ./tests/integration/scripts/delete-hosted-mobile-token.mjs --tenant-id <id> [--token-id <id>] [--token <raw-token>] [--cloud-host <user@host>]',
);
process.exit(1);
}
function parseArgs(argv) {
const parsed = {
cloudHost: DEFAULT_CLOUD_HOST,
tenantId: '',
token: '',
tokenId: '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '--tenant-id':
parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id');
index += 1;
break;
case '--token-id':
parsed.tokenId = argv[index + 1] ?? usage('missing value for --token-id');
index += 1;
break;
case '--token':
parsed.token = argv[index + 1] ?? usage('missing value for --token');
index += 1;
break;
case '--cloud-host':
parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host');
index += 1;
break;
case '--help':
case '-h':
usage();
break;
default:
usage(`unsupported flag ${arg}`);
}
}
parsed.tenantId = String(parsed.tenantId).trim();
parsed.tokenId = String(parsed.tokenId).trim();
parsed.token = String(parsed.token).trim();
if (!parsed.tenantId) {
usage('--tenant-id is required');
}
if (!parsed.tokenId && !parsed.token) {
usage('either --token-id or --token is required');
}
return parsed;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-token-delete-'));
try {
const result = deleteHostedRelayMobileToken({
cloudHost: args.cloudHost,
tenantId: args.tenantId,
tempDir,
token: args.token || null,
tokenId: args.tokenId || null,
});
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
result.runtimeRestarted = true;
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
}
}
main();

View file

@ -0,0 +1,103 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
resolveHostedTenantRootDataDir,
runRemote,
shellQuote,
} from './hosted-tenant-runtime.mjs';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
function buildLocalHelper(tempDir) {
const binaryPath = path.join(tempDir, 'relay-mobile-token-helper');
execFileSync('go', [
'build',
'-buildvcs=false',
'-o',
binaryPath,
'./tests/integration/scripts/relay-mobile-token-helper.go',
], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: {
...process.env,
CGO_ENABLED: '0',
GOARCH: 'amd64',
GOOS: 'linux',
},
stdio: 'pipe',
});
return binaryPath;
}
function runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }) {
const localBinaryPath = buildLocalHelper(tempDir);
const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`;
try {
execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], {
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
stdio: 'pipe',
});
runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`);
return runRemote(cloudHost, [
shellQuote(remoteBinaryPath),
...args.map((value) => shellQuote(value)),
].join(' '));
} finally {
try {
runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`);
} catch {}
}
}
export function createHostedRelayMobileToken({ cloudHost, issuedVia = null, tenantId, tempDir }) {
const args = [
'create',
'--data-dir',
resolveHostedTenantRootDataDir(tenantId),
'--org-id',
tenantId,
];
if (issuedVia) {
args.push('--issued-via', issuedVia);
}
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
}
export function deleteHostedRelayMobileToken({
cloudHost,
tenantId,
tempDir,
token = null,
tokenId = null,
}) {
const args = [
'delete',
'--data-dir',
resolveHostedTenantRootDataDir(tenantId),
];
if (tokenId) {
args.push('--token-id', tokenId);
}
if (token) {
args.push('--token', token);
}
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
}
export function validateHostedRelayMobileToken({ cloudHost, tenantId, tempDir, token }) {
const args = [
'validate',
'--data-dir',
resolveHostedTenantRootDataDir(tenantId),
'--token',
token,
];
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
}

View file

@ -37,6 +37,13 @@ type validationResult struct {
Record *helperTokenRecord `json:"record,omitempty"`
}
type deleteResult struct {
Action string `json:"action"`
DataDir string `json:"dataDir"`
Deleted bool `json:"deleted"`
Record *helperTokenRecord `json:"record,omitempty"`
}
type helperTokenRecord struct {
ID string `json:"id"`
Metadata map[string]string `json:"metadata,omitempty"`
@ -56,6 +63,8 @@ func usage(message string) {
}
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go create --data-dir <dir> --org-id <id> [options]")
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go delete --data-dir <dir> (--token-id <id> | --token <raw-token>)")
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go validate --data-dir <dir> --token <raw-token>")
os.Exit(1)
}
@ -87,6 +96,60 @@ func pruneExistingProofTokens(tokens []config.APITokenRecord, orgID, issuedVia s
return filtered, pruned
}
func toHelperTokenRecord(record config.APITokenRecord) helperTokenRecord {
return helperTokenRecord{
ID: record.ID,
Metadata: record.Metadata,
Name: record.Name,
OrgID: record.OrgID,
Scopes: append([]string{}, record.Scopes...),
}
}
func findTokenRecord(tokens []config.APITokenRecord, tokenID, rawToken string) (*config.APITokenRecord, error) {
scopedTokenID := strings.TrimSpace(tokenID)
scopedRawToken := strings.TrimSpace(rawToken)
if scopedTokenID == "" && scopedRawToken == "" {
return nil, fmt.Errorf("either token id or raw token is required")
}
if scopedRawToken != "" {
cfg := &config.Config{APITokens: tokens}
record, ok := cfg.ValidateAPIToken(scopedRawToken)
if !ok || record == nil {
return nil, nil
}
scopedTokenID = strings.TrimSpace(record.ID)
}
for idx := range tokens {
if strings.TrimSpace(tokens[idx].ID) == scopedTokenID {
return &tokens[idx], nil
}
}
return nil, nil
}
func deleteTokenRecord(tokens []config.APITokenRecord, tokenID string) ([]config.APITokenRecord, *config.APITokenRecord) {
scopedTokenID := strings.TrimSpace(tokenID)
filtered := make([]config.APITokenRecord, 0, len(tokens))
var removed *config.APITokenRecord
for idx := range tokens {
record := tokens[idx]
if removed == nil && strings.TrimSpace(record.ID) == scopedTokenID {
recordCopy := record
removed = &recordCopy
continue
}
filtered = append(filtered, record)
}
return filtered, removed
}
func createRelayMobileToken(args []string) {
flags := flag.NewFlagSet("create", flag.ExitOnError)
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
@ -152,19 +215,72 @@ func createRelayMobileToken(args []string) {
DataDir: scopedDataDir,
OrgID: scopedOrgID,
PrunedCount: prunedCount,
Record: helperTokenRecord{
ID: record.ID,
Metadata: record.Metadata,
Name: record.Name,
OrgID: record.OrgID,
Scopes: append([]string{}, record.Scopes...),
},
Token: rawToken,
Record: toHelperTokenRecord(*record),
Token: rawToken,
}); err != nil {
fatalf("encode result: %v", err)
}
}
func deleteRelayMobileToken(args []string) {
flags := flag.NewFlagSet("delete", flag.ExitOnError)
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
tokenID := flags.String("token-id", "", "Token record id to delete")
rawToken := flags.String("token", "", "Raw token value to resolve and delete")
if err := flags.Parse(args); err != nil {
fatalf("%v", err)
}
scopedDataDir := strings.TrimSpace(*dataDir)
if scopedDataDir == "" {
usage("--data-dir is required")
}
persistence := config.NewConfigPersistence(scopedDataDir)
tokens, err := persistence.LoadAPITokens()
if err != nil {
fatalf("load api tokens: %v", err)
}
record, err := findTokenRecord(tokens, *tokenID, *rawToken)
if err != nil {
fatalf("resolve token: %v", err)
}
if record == nil {
fatalf("token not found")
}
filteredTokens, removed := deleteTokenRecord(tokens, record.ID)
if removed == nil {
fatalf("token not found")
}
cfg := &config.Config{APITokens: filteredTokens}
cfg.SortAPITokens()
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
fatalf("persist api tokens: %v", err)
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(deleteResult{
Action: "delete",
DataDir: scopedDataDir,
Deleted: true,
Record: ptrToHelperTokenRecord(removed),
}); err != nil {
fatalf("encode result: %v", err)
}
}
func ptrToHelperTokenRecord(record *config.APITokenRecord) *helperTokenRecord {
if record == nil {
return nil
}
converted := toHelperTokenRecord(*record)
return &converted
}
func validateRelayMobileToken(args []string) {
flags := flag.NewFlagSet("validate", flag.ExitOnError)
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
@ -196,13 +312,7 @@ func validateRelayMobileToken(args []string) {
Found: ok && record != nil,
}
if ok && record != nil {
result.Record = &helperTokenRecord{
ID: record.ID,
Metadata: record.Metadata,
Name: record.Name,
OrgID: record.OrgID,
Scopes: append([]string{}, record.Scopes...),
}
result.Record = ptrToHelperTokenRecord(record)
}
encoder := json.NewEncoder(os.Stdout)
@ -222,6 +332,8 @@ func main() {
switch strings.ToLower(strings.TrimSpace(os.Args[1])) {
case "create":
createRelayMobileToken(os.Args[2:])
case "delete":
deleteRelayMobileToken(os.Args[2:])
case "validate":
validateRelayMobileToken(os.Args[2:])
case "--help", "-h", "help":

View file

@ -0,0 +1,54 @@
package main
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestFindTokenRecordResolvesRawToken(t *testing.T) {
rawToken, err := config.NewAPITokenRecord("relay-mobile-raw-token-123.12345678", "Pulse Mobile", []string{config.ScopeRelayMobileAccess})
if err != nil {
t.Fatalf("NewAPITokenRecord: %v", err)
}
otherToken, err := config.NewAPITokenRecord("other-raw-token-123.12345678", "Other", []string{config.ScopeRelayMobileAccess})
if err != nil {
t.Fatalf("NewAPITokenRecord other: %v", err)
}
record, err := findTokenRecord([]config.APITokenRecord{*rawToken, *otherToken}, "", "relay-mobile-raw-token-123.12345678")
if err != nil {
t.Fatalf("findTokenRecord: %v", err)
}
if record == nil {
t.Fatal("expected record, got nil")
}
if record.ID != rawToken.ID {
t.Fatalf("record ID = %q, want %q", record.ID, rawToken.ID)
}
}
func TestDeleteTokenRecordRemovesOnlyMatchingToken(t *testing.T) {
first, err := config.NewAPITokenRecord("relay-mobile-delete-1.12345678", "First", []string{config.ScopeRelayMobileAccess})
if err != nil {
t.Fatalf("NewAPITokenRecord first: %v", err)
}
second, err := config.NewAPITokenRecord("relay-mobile-delete-2.12345678", "Second", []string{config.ScopeRelayMobileAccess})
if err != nil {
t.Fatalf("NewAPITokenRecord second: %v", err)
}
filtered, removed := deleteTokenRecord([]config.APITokenRecord{*first, *second}, second.ID)
if removed == nil {
t.Fatal("expected removed token, got nil")
}
if removed.ID != second.ID {
t.Fatalf("removed ID = %q, want %q", removed.ID, second.ID)
}
if len(filtered) != 1 {
t.Fatalf("filtered len = %d, want 1", len(filtered))
}
if filtered[0].ID != first.ID {
t.Fatalf("remaining ID = %q, want %q", filtered[0].ID, first.ID)
}
}