Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## [Unreleased]

### 2026-04-03
- **Feat**: マルチテナント対応 — ログイン時のテナント名指定 (`-s <テナント名>`)、`tenantId` / `availableTenants` の config 保存、`profile use` 時のトークン自動リフレッシュ (#90)
- **Feat**: `geonic cli update` コマンドを追加 — `npm update -g` のエイリアスとして CLI を最新版に更新可能に (#94)
- **Fix**: 複数ターミナルで同一プロファイルを使用時、refresh token rotation によりセッションが無効化される問題を修正 — トークンリフレッシュ前に config を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する (#93)

Expand Down
60 changes: 51 additions & 9 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,13 @@ function createLoginCommand(): Command {

const client = createClient(cmd);
const body: Record<string, string> = { email, password };
if (loginOpts.tenantId) {
body.tenantId = loginOpts.tenantId;

// --tenant-id takes priority
const requestTenantId = loginOpts.tenantId;
const serviceFlag = globalOpts.service;

if (requestTenantId) {
body.tenantId = requestTenantId;
}

const response = await client.rawRequest("POST", "/auth/login", {
Expand All @@ -117,12 +122,31 @@ function createLoginCommand(): Command {
const availableTenants = data.availableTenants as TenantChoice[] | undefined;
let finalTenantId = data.tenantId as string | undefined;

if (availableTenants && availableTenants.length > 1 && !loginOpts.tenantId) {
const selectedTenantId = await promptTenantSelection(availableTenants, finalTenantId);
if (selectedTenantId && selectedTenantId !== finalTenantId) {
if (availableTenants && availableTenants.length > 1 && !requestTenantId) {
// If --service was provided, resolve tenant by name or ID match
let resolvedTenantId: string | undefined;
if (serviceFlag) {
const match = availableTenants.find(
(t) => t.name === serviceFlag || t.tenantId === serviceFlag,
);
if (match) {
resolvedTenantId = match.tenantId;
} else {
printError(
`Tenant "${serviceFlag}" not found. Available: ${availableTenants.map((t) => t.name ?? t.tenantId).join(", ")}`,
);
process.exit(1);
}
}

if (!resolvedTenantId) {
resolvedTenantId = await promptTenantSelection(availableTenants, finalTenantId);
}

if (resolvedTenantId && resolvedTenantId !== finalTenantId) {
// Re-login with selected tenant
const reloginResponse = await client.rawRequest("POST", "/auth/login", {
body: { email, password, tenantId: selectedTenantId },
body: { email, password, tenantId: resolvedTenantId },
skipTenantHeader: true,
});
const reloginData = reloginResponse.data as Record<string, unknown>;
Expand All @@ -133,7 +157,7 @@ function createLoginCommand(): Command {
}
token = newToken;
refreshToken = reloginData.refreshToken as string | undefined;
finalTenantId = selectedTenantId;
finalTenantId = resolvedTenantId;
}
}

Expand All @@ -146,12 +170,26 @@ function createLoginCommand(): Command {
}
if (finalTenantId) {
config.service = finalTenantId;
config.tenantId = finalTenantId;
} else {
delete config.service;
delete config.tenantId;
}
if (availableTenants && availableTenants.length > 0) {
config.availableTenants = availableTenants.map((t) => ({
tenantId: t.tenantId,
...(t.name ? { name: t.name } : {}),
role: t.role,
}));
} else {
delete config.availableTenants;
}
saveConfig(config, globalOpts.profile);

printSuccess("Login successful. Token saved to config.");
const tenantLabel = finalTenantId
? ` (tenant: ${availableTenants?.find((t) => t.tenantId === finalTenantId)?.name ?? finalTenantId})`
: "";
printSuccess(`Login successful${tenantLabel}. Token saved to config.`);
}),
);
}
Expand Down Expand Up @@ -395,9 +433,13 @@ export function registerAuthCommands(program: Command): void {
"geonic auth login --client-credentials --client-id MY_ID --client-secret MY_SECRET",
},
{
description: "Login to a specific tenant",
description: "Login to a specific tenant by ID",
command: "geonic auth login --tenant-id my-tenant",
},
{
description: "Login to a tenant by name",
command: "geonic auth login -s demo_smartcity",
},
]);
auth.addCommand(login);

Expand Down
55 changes: 51 additions & 4 deletions src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
createProfile,
deleteProfile,
loadConfig,
saveConfig,
validateUrl,
} from "../config.js";
import { printSuccess, printInfo, printError } from "../output.js";
import { printSuccess, printInfo, printError, printWarning } from "../output.js";
import { getTokenStatus } from "../token.js";
import { addExamples } from "./help.js";

export function registerProfileCommands(program: Command): void {
Expand All @@ -33,15 +36,52 @@ export function registerProfileCommands(program: Command): void {

const use = profile
.command("use <name>")
.description("Switch active profile")
.action((name: string) => {
.description("Switch active profile (auto-refreshes expired tokens)")
.action(async (name: string) => {
try {
setCurrentProfile(name);
printSuccess(`Switched to profile "${name}".`);
} catch (err) {
printError((err as Error).message);
process.exit(1);
}

const config = loadConfig(name);
const tenantLabel = config.tenantId
? ` (tenant: ${config.availableTenants?.find((t) => t.tenantId === config.tenantId)?.name ?? config.tenantId})`
: "";

// Auto-refresh expired token if refreshToken is available
if (config.token && config.refreshToken && config.url) {
const status = getTokenStatus(config.token);
if (status.isExpired || status.isExpiringSoon) {
try {
const baseUrl = validateUrl(config.url);
const url = new URL("/auth/refresh", baseUrl).toString();
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: config.refreshToken }),
});
if (response.ok) {
const data = (await response.json()) as Record<string, unknown>;
const newToken = (data.accessToken ?? data.token) as string | undefined;
const newRefreshToken = data.refreshToken as string | undefined;
if (newToken) {
config.token = newToken;
if (newRefreshToken) config.refreshToken = newRefreshToken;
saveConfig(config, name);
printSuccess(`Switched to profile "${name}"${tenantLabel}. Token refreshed.`);
return;
}
}
printWarning("Token refresh failed. You may need to re-login.");
} catch {
printWarning("Token refresh failed. You may need to re-login.");
}
}
}

printSuccess(`Switched to profile "${name}"${tenantLabel}.`);
});

addExamples(use, [
Expand Down Expand Up @@ -108,6 +148,13 @@ export function registerProfileCommands(program: Command): void {
typeof value === "string"
) {
console.log(`${key}: ***`);
} else if (key === "availableTenants" && Array.isArray(value)) {
console.log(`${key}:`);
for (const t of value as { tenantId: string; name?: string; role: string }[]) {
const label = t.name ? `${t.name} (${t.tenantId})` : t.tenantId;
const current = t.tenantId === config.tenantId ? " ← current" : "";
console.log(` - ${label} [${t.role}]${current}`);
}
} else {
console.log(`${key}: ${value}`);
}
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function migrateV1ToV2(data: Record<string, unknown>): GdbConfigFile {
const knownKeys = [
"url",
"service",
"tenantId",
"token",
"refreshToken",
"format",
Expand Down
4 changes: 3 additions & 1 deletion src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export async function promptPassword(): Promise<string> {

export interface TenantChoice {
tenantId: string;
name?: string;
role: string;
}

Expand All @@ -120,7 +121,8 @@ export async function promptTenantSelection(
const t = tenants[i];
const current = t.tenantId === currentTenantId ? " ← current" : "";
const marker = t.tenantId === currentTenantId ? " *" : " ";
console.log(`${marker} ${i + 1}) ${t.tenantId} (${t.role})${current}`);
const label = t.name ? `${t.name} (${t.tenantId})` : t.tenantId;
console.log(`${marker} ${i + 1}) ${label} [${t.role}]${current}`);
}
for (;;) {
const answer = await rl.question("\nSelect tenant number (Enter to keep current): ");
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
export type OutputFormat = "json" | "table" | "geojson";

export interface TenantInfo {
tenantId: string;
name?: string;
role: string;
}

export interface GdbConfig {
url?: string;
service?: string;
tenantId?: string;
availableTenants?: TenantInfo[];
token?: string;
refreshToken?: string;
format?: OutputFormat;
Expand Down
123 changes: 123 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,129 @@ describe("auth commands", () => {
"default",
);
});

it("saves tenantId and availableTenants to config", async () => {
const tenants = [
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
{ tenantId: "city_b", role: "user" },
];
client.rawRequest.mockResolvedValue(
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
);
vi.mocked(promptTenantSelection).mockResolvedValue(undefined);
const program = makeProgram();
await runCommand(program, ["auth", "login"]);
expect(saveConfig).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: "city_a",
availableTenants: [
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
{ tenantId: "city_b", role: "user" },
],
}),
"default",
);
});

it("resolves tenant by name via --service flag", async () => {
const tenants = [
{ tenantId: "tid-aaa", name: "demo_smartcity", role: "tenant_admin" },
{ tenantId: "tid-bbb", name: "demo_bousai", role: "user" },
];
vi.mocked(resolveOptions).mockReturnValue({
url: "http://localhost:3000",
profile: "default",
token: "test-token",
format: "json",
service: "demo_bousai",
} as never);
client.rawRequest
.mockResolvedValueOnce(
mockResponse({ accessToken: "tok-a", tenantId: "tid-aaa", availableTenants: tenants }),
)
.mockResolvedValueOnce(
mockResponse({ accessToken: "tok-b", refreshToken: "ref-b", tenantId: "tid-bbb" }),
);
const program = makeProgram();
await runCommand(program, ["auth", "login"]);
// Should NOT prompt — resolved by --service name
expect(promptTenantSelection).not.toHaveBeenCalled();
// Should re-login with resolved tenantId
expect(client.rawRequest).toHaveBeenLastCalledWith("POST", "/auth/login", {
body: { email: "user@example.com", password: "pass123", tenantId: "tid-bbb" },
skipTenantHeader: true,
});
expect(saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ token: "tok-b", service: "tid-bbb", tenantId: "tid-bbb" }),
"default",
);
});

it("resolves tenant by tenantId via --service flag", async () => {
const tenants = [
{ tenantId: "city_a", role: "tenant_admin" },
{ tenantId: "city_b", role: "user" },
];
vi.mocked(resolveOptions).mockReturnValue({
url: "http://localhost:3000",
profile: "default",
token: "test-token",
format: "json",
service: "city_b",
} as never);
client.rawRequest
.mockResolvedValueOnce(
mockResponse({ accessToken: "tok-a", tenantId: "city_a", availableTenants: tenants }),
)
.mockResolvedValueOnce(
mockResponse({ accessToken: "tok-b", tenantId: "city_b" }),
);
const program = makeProgram();
await runCommand(program, ["auth", "login"]);
expect(promptTenantSelection).not.toHaveBeenCalled();
expect(saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ token: "tok-b", service: "city_b" }),
"default",
);
});

it("prints error when --service tenant name not found", async () => {
const tenants = [
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
{ tenantId: "city_b", role: "user" },
];
vi.mocked(resolveOptions).mockReturnValue({
url: "http://localhost:3000",
profile: "default",
token: "test-token",
format: "json",
service: "nonexistent",
} as never);
client.rawRequest.mockResolvedValue(
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
);
const program = makeProgram();
await expect(
runCommand(program, ["auth", "login"]),
).rejects.toThrow("process.exit");
expect(printError).toHaveBeenCalledWith(
expect.stringContaining('Tenant "nonexistent" not found'),
);
});

it("includes tenant label in success message", async () => {
const tenants = [
{ tenantId: "city_a", name: "Smart City A", role: "tenant_admin" },
];
client.rawRequest.mockResolvedValue(
mockResponse({ accessToken: "tok", tenantId: "city_a", availableTenants: tenants }),
);
const program = makeProgram();
await runCommand(program, ["auth", "login"]);
expect(printSuccess).toHaveBeenCalledWith(
"Login successful (tenant: Smart City A). Token saved to config.",
);
});
});

describe("auth logout", () => {
Expand Down
Loading
Loading