Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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)
- **Fix**: 複数ターミナルで同一プロファイルを使用時、refresh token rotation によりセッションが無効化される問題を修正 — トークンリフレッシュ前に config を再読み込みし、別プロセスが既にリフレッシュ済みならそのトークンを使用する (#93)

## [0.9.0] - 2026-03-25
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