Skip to content

Commit f18db28

Browse files
authored
Merge pull request #51 from geolonia/me-oauth-clients
feat: add `me oauth-clients` commands for self-service credential management
2 parents 029e8ed + c38881c commit f18db28

12 files changed

Lines changed: 530 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
## [Unreleased]
99

10+
### 2026-03-05
11+
- **Feat**: `geonic me oauth-clients` コマンドを追加 — ユーザー自身が OAuth Client Credentials をセルフサービスで発行・管理 (#51)
12+
- **Feat**: `--save` オプションで credentials を config に保存し、自動再認証を有効化 (#51)
13+
- **Feat**: client_credentials grant による自動トークン再取得フォールバックを追加 (#51)
14+
1015
### 2026-03-04
1116
- **CI**: npm publish 前に CI ワークフロー(E2E 含む)の成功を必須化 (#50)
1217

package-lock.json

Lines changed: 13 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client.ts

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ClientOptions, ClientResponse, NgsiError } from "./types.js";
2+
import { clientCredentialsGrant } from "./oauth.js";
23

34
export class DryRunSignal extends Error {
45
constructor() {
@@ -13,6 +14,8 @@ export class GdbClient {
1314
private token?: string;
1415
private refreshToken?: string;
1516
private apiKey?: string;
17+
private clientId?: string;
18+
private clientSecret?: string;
1619
private onTokenRefresh?: (token: string, refreshToken?: string) => void;
1720
private verbose: boolean;
1821
private dryRun: boolean;
@@ -24,6 +27,8 @@ export class GdbClient {
2427
this.token = options.token;
2528
this.refreshToken = options.refreshToken;
2629
this.apiKey = options.apiKey;
30+
this.clientId = options.clientId;
31+
this.clientSecret = options.clientSecret;
2732
this.onTokenRefresh = options.onTokenRefresh;
2833
this.verbose = options.verbose ?? false;
2934
this.dryRun = options.dryRun ?? false;
@@ -154,7 +159,7 @@ export class GdbClient {
154159
}
155160

156161
private canRefresh(): boolean {
157-
return !!this.refreshToken && !this.apiKey;
162+
return (!!this.refreshToken || (!!this.clientId && !!this.clientSecret)) && !this.apiKey;
158163
}
159164

160165
private async performTokenRefresh(): Promise<boolean> {
@@ -169,32 +174,50 @@ export class GdbClient {
169174
}
170175

171176
private async doRefresh(): Promise<boolean> {
172-
/* v8 ignore next -- canRefresh() guards this, refreshToken always exists here */
173-
if (!this.refreshToken) return false;
174-
175-
try {
176-
const url = this.buildUrl("/auth/refresh");
177-
const response = await fetch(url, {
178-
method: "POST",
179-
headers: { "Content-Type": "application/json" },
180-
body: JSON.stringify({ refreshToken: this.refreshToken }),
181-
});
182-
183-
if (!response.ok) return false;
184-
185-
const data = (await response.json()) as Record<string, unknown>;
186-
const newToken = (data.accessToken ?? data.token) as string | undefined;
187-
const newRefreshToken = data.refreshToken as string | undefined;
188-
189-
if (!newToken) return false;
177+
// Try refreshToken first
178+
if (this.refreshToken) {
179+
try {
180+
const url = this.buildUrl("/auth/refresh");
181+
const response = await fetch(url, {
182+
method: "POST",
183+
headers: { "Content-Type": "application/json" },
184+
body: JSON.stringify({ refreshToken: this.refreshToken }),
185+
});
186+
187+
if (response.ok) {
188+
const data = (await response.json()) as Record<string, unknown>;
189+
const newToken = (data.accessToken ?? data.token) as string | undefined;
190+
const newRefreshToken = data.refreshToken as string | undefined;
191+
192+
if (newToken) {
193+
this.token = newToken;
194+
if (newRefreshToken) this.refreshToken = newRefreshToken;
195+
this.onTokenRefresh?.(newToken, newRefreshToken);
196+
return true;
197+
}
198+
}
199+
} catch {
200+
// Fall through to client credentials
201+
}
202+
}
190203

191-
this.token = newToken;
192-
if (newRefreshToken) this.refreshToken = newRefreshToken;
193-
this.onTokenRefresh?.(newToken, newRefreshToken);
194-
return true;
195-
} catch {
196-
return false;
204+
// Fallback: client_credentials grant
205+
if (this.clientId && this.clientSecret) {
206+
try {
207+
const result = await clientCredentialsGrant({
208+
baseUrl: this.baseUrl,
209+
clientId: this.clientId,
210+
clientSecret: this.clientSecret,
211+
});
212+
this.token = result.access_token;
213+
this.onTokenRefresh?.(result.access_token);
214+
return true;
215+
} catch {
216+
return false;
217+
}
197218
}
219+
220+
return false;
198221
}
199222

200223
private async executeRequest<T>(

src/commands/auth.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isInteractive, promptEmail, promptPassword } from "../prompt.js";
1212
import { getTokenStatus, formatDuration } from "../token.js";
1313
import { clientCredentialsGrant } from "../oauth.js";
1414
import { addExamples } from "./help.js";
15+
import { addMeOAuthClientsSubcommand } from "./me-oauth-clients.js";
1516

1617
function createLoginCommand(): Command {
1718
return new Command("login")
@@ -224,6 +225,11 @@ export function registerAuthCommands(program: Command): void {
224225
// me command (top-level, maps to /me API endpoint)
225226
const me = program
226227
.command("me")
228+
.description("Display current authenticated user and manage user resources");
229+
230+
// Default action: show user info when no subcommand is given
231+
const meInfo = me
232+
.command("info", { isDefault: true, hidden: true })
227233
.description("Display current authenticated user")
228234
.action(createMeAction());
229235

@@ -232,8 +238,22 @@ export function registerAuthCommands(program: Command): void {
232238
description: "Show current user info",
233239
command: "geonic me",
234240
},
241+
{
242+
description: "List your OAuth clients",
243+
command: "geonic me oauth-clients list",
244+
},
245+
]);
246+
247+
addExamples(meInfo, [
248+
{
249+
description: "Show current user info",
250+
command: "geonic me",
251+
},
235252
]);
236253

254+
// Add me oauth-clients subcommands
255+
addMeOAuthClientsSubcommand(me);
256+
237257
// Backward-compatible hidden aliases
238258
program.addCommand(createLoginCommand(), { hidden: true });
239259
program.addCommand(createLogoutCommand(), { hidden: true });

src/commands/me-oauth-clients.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { Command } from "commander";
2+
import { withErrorHandler, createClient, resolveOptions, getFormat, outputResponse } from "../helpers.js";
3+
import { loadConfig, saveConfig, validateUrl } from "../config.js";
4+
import { parseJsonInput } from "../input.js";
5+
import { printSuccess, printError, printInfo, printWarning } from "../output.js";
6+
import { clientCredentialsGrant } from "../oauth.js";
7+
import { addExamples } from "./help.js";
8+
9+
export function addMeOAuthClientsSubcommand(me: Command): void {
10+
const oauthClients = me
11+
.command("oauth-clients")
12+
.description("Manage your OAuth clients");
13+
14+
// oauth-clients list
15+
const list = oauthClients
16+
.command("list")
17+
.description("List your OAuth clients")
18+
.action(
19+
withErrorHandler(async (_opts: unknown, cmd: Command) => {
20+
const client = createClient(cmd);
21+
const format = getFormat(cmd);
22+
const response = await client.rawRequest("GET", "/me/oauth-clients");
23+
outputResponse(response, format);
24+
}),
25+
);
26+
27+
addExamples(list, [
28+
{
29+
description: "List your OAuth clients",
30+
command: "geonic me oauth-clients list",
31+
},
32+
]);
33+
34+
// oauth-clients create
35+
const create = oauthClients
36+
.command("create [json]")
37+
.description("Create a new OAuth client")
38+
.option("--name <name>", "Client name")
39+
.option("--scopes <scopes>", "Allowed scopes (comma-separated)")
40+
.option("--save", "Save credentials to config for automatic re-authentication")
41+
.action(
42+
withErrorHandler(async (json: unknown, _opts: unknown, cmd: Command) => {
43+
const opts = cmd.opts() as {
44+
name?: string;
45+
scopes?: string;
46+
save?: boolean;
47+
};
48+
49+
let body: unknown;
50+
if (json) {
51+
body = await parseJsonInput(json as string | undefined);
52+
} else if (opts.name || opts.scopes) {
53+
// Build body from flags
54+
const payload: Record<string, unknown> = {};
55+
if (opts.name) payload.clientName = opts.name;
56+
if (opts.scopes) payload.allowedScopes = opts.scopes.split(",").map((s) => s.trim());
57+
body = payload;
58+
} else {
59+
// Read from stdin (pipe or interactive)
60+
body = await parseJsonInput();
61+
}
62+
63+
const client = createClient(cmd);
64+
const format = getFormat(cmd);
65+
const response = await client.rawRequest("POST", "/me/oauth-clients", { body });
66+
67+
const data = response.data as Record<string, unknown>;
68+
69+
if (opts.save) {
70+
const globalOpts = resolveOptions(cmd);
71+
const clientId = data.clientId as string | undefined;
72+
const clientSecret = data.clientSecret as string | undefined;
73+
74+
if (!clientId || !clientSecret) {
75+
printError("Response missing clientId or clientSecret. Cannot save credentials.");
76+
outputResponse(response, format);
77+
printSuccess("OAuth client created.");
78+
return;
79+
}
80+
81+
// Perform client_credentials grant to get a fresh token
82+
const baseUrl = validateUrl(globalOpts.url!);
83+
const tokenResult = await clientCredentialsGrant({
84+
baseUrl,
85+
clientId,
86+
clientSecret,
87+
scope: (data.allowedScopes as string[] | undefined)?.join(" "),
88+
});
89+
90+
const config = loadConfig(globalOpts.profile);
91+
config.clientId = clientId;
92+
config.clientSecret = clientSecret;
93+
config.token = tokenResult.access_token;
94+
delete config.refreshToken;
95+
saveConfig(config, globalOpts.profile);
96+
97+
printInfo("Client credentials saved to config. Auto-reauth enabled.");
98+
} else {
99+
printWarning(
100+
"Save the clientSecret now — it will not be shown again.",
101+
);
102+
}
103+
104+
outputResponse(response, format);
105+
printSuccess("OAuth client created.");
106+
}),
107+
);
108+
109+
addExamples(create, [
110+
{
111+
description: "Create an OAuth client with flags",
112+
command: "geonic me oauth-clients create --name my-ci-bot --scopes read:entities,write:entities",
113+
},
114+
{
115+
description: "Create and save credentials for auto-reauth",
116+
command: "geonic me oauth-clients create --name my-ci-bot --save",
117+
},
118+
{
119+
description: "Create an OAuth client from JSON",
120+
command: 'geonic me oauth-clients create \'{"clientName":"my-bot","allowedScopes":["read:entities"]}\'',
121+
},
122+
]);
123+
124+
// oauth-clients delete
125+
const del = oauthClients
126+
.command("delete <id>")
127+
.description("Delete an OAuth client")
128+
.action(
129+
withErrorHandler(async (id: unknown, _opts: unknown, cmd: Command) => {
130+
const client = createClient(cmd);
131+
await client.rawRequest(
132+
"DELETE",
133+
`/me/oauth-clients/${encodeURIComponent(String(id))}`,
134+
);
135+
printSuccess("OAuth client deleted.");
136+
}),
137+
);
138+
139+
addExamples(del, [
140+
{
141+
description: "Delete an OAuth client",
142+
command: "geonic me oauth-clients delete <client-id>",
143+
},
144+
]);
145+
}

src/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export function createClient(cmd: Command): GdbClient {
4141
service: opts.service,
4242
token: opts.token,
4343
refreshToken: usingCliToken ? undefined : config.refreshToken,
44+
clientId: usingCliToken ? undefined : config.clientId,
45+
clientSecret: usingCliToken ? undefined : config.clientSecret,
4446
apiKey: opts.apiKey,
4547
onTokenRefresh: usingCliToken
4648
? undefined

src/oauth.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,18 @@ export async function clientCredentialsGrant(options: {
1414
const url = new URL("/oauth/token", options.baseUrl).toString();
1515
const credentials = Buffer.from(`${options.clientId}:${options.clientSecret}`).toString("base64");
1616

17-
const params = new URLSearchParams();
18-
params.set("grant_type", "client_credentials");
17+
const body: Record<string, string> = { grant_type: "client_credentials" };
1918
if (options.scope) {
20-
params.set("scope", options.scope);
19+
body.scope = options.scope;
2120
}
2221

2322
const response = await fetch(url, {
2423
method: "POST",
2524
headers: {
26-
"Content-Type": "application/x-www-form-urlencoded",
25+
"Content-Type": "application/json",
2726
Authorization: `Basic ${credentials}`,
2827
},
29-
body: params.toString(),
28+
body: JSON.stringify(body),
3029
});
3130

3231
if (!response.ok) {

0 commit comments

Comments
 (0)