From 5a09d5b070dfe47fc52cf7af7d3b603f3f144cce Mon Sep 17 00:00:00 2001 From: monoid Date: Thu, 10 Apr 2025 21:36:48 +0900 Subject: [PATCH] refactor: all --- api/base-api.ts | 36 +++ api/oauth2-api.ts | 82 ++++++ api/token-api.ts | 51 ++++ app.ts | 530 +++++++++++++++---------------------- types.ts | 24 ++ util/credential-manager.ts | 53 ++++ util/helpers.ts | 16 ++ 7 files changed, 482 insertions(+), 310 deletions(-) create mode 100644 api/base-api.ts create mode 100644 api/oauth2-api.ts create mode 100644 api/token-api.ts create mode 100644 types.ts create mode 100644 util/credential-manager.ts create mode 100644 util/helpers.ts diff --git a/api/base-api.ts b/api/base-api.ts new file mode 100644 index 0000000..ac37274 --- /dev/null +++ b/api/base-api.ts @@ -0,0 +1,36 @@ +export class ApiError extends Error { + constructor(public readonly statusCode: number, message: string) { + super(message); + this.name = "ApiError"; + } +} + +export class BaseApi { + constructor(protected readonly baseUrl: string) {} + + protected async request( + endpoint: string, + options: { + method: string, + headers: Record, + body?: string + } + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const res = await fetch(url, options); + + if (!res.ok) { + // Log the error response for debugging + console.error(`Failed with ${res.status} ${res.statusText}`); + const errorText = await res.json(); + console.error("Response:", errorText); + + throw new ApiError(res.status, errorText.message); + } + if (res.status === 204) { + return undefined as T; // No content: ; + } + const data = await res.json(); + return data as T; + } +} diff --git a/api/oauth2-api.ts b/api/oauth2-api.ts new file mode 100644 index 0000000..2507301 --- /dev/null +++ b/api/oauth2-api.ts @@ -0,0 +1,82 @@ +import { BaseApi } from "./base-api.ts"; + +export type Oauth2Application = { + id: number, + name: string, + redirect_uris: string[], + client_id: string, + client_secret: string, + confidential_client: boolean, + /** + * @format date-time + */ + created: string, +} + +export class OAuth2Api extends BaseApi { + private token: string; + + constructor(baseUrl: string, token: string) { + super(baseUrl); + this.token = token; + } + + // Method to update token if needed later + updateToken(token: string): void { + this.token = token; + } + + async createOauth2Application(params: { + name: string, + redirect_uris: string[], + confidential_client?: boolean, + }): Promise { + return await this.request(`/api/v1/user/applications/oauth2`, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + } + + async getOauth2Applications(): Promise { + return await this.request(`/api/v1/user/applications/oauth2`, { + method: "GET", + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + }); + } + + async deleteOauth2Application(id: number): Promise { + if (!this.token) { + throw new Error("Authentication required. Please login first."); + } + + await this.request(`/api/v1/user/applications/oauth2/${id}`, { + method: "DELETE", + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + }); + } + + async updateOauth2Application(id: number, params: { + name: string, + redirect_uris: string[], + confidential_client?: boolean, + }): Promise { + return await this.request(`/api/v1/user/applications/oauth2/${id}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + } +} diff --git a/api/token-api.ts b/api/token-api.ts new file mode 100644 index 0000000..d967fd2 --- /dev/null +++ b/api/token-api.ts @@ -0,0 +1,51 @@ +import { BaseApi } from "./base-api.ts"; + +export type Token = { + id: number, + name: string, + scopes: string[], + sha1: string, + token_last_eight: string, +} + +export class TokenApi extends BaseApi { + constructor(baseUrl: string, private tokenName: string) { + super(baseUrl); + } + + async listTokens(username: string, secret: string): Promise { + return await this.request(`/api/v1/users/${username}/tokens`, { + method: "GET", + headers: { + "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, + "Content-Type": "application/json", + }, + }); + } + + async deleteToken(username: string, secret: string, tokenId: number): Promise { + const result = await this.request(`/api/v1/users/${username}/tokens/${tokenId}`, { + method: "DELETE", + headers: { + "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, + "Content-Type": "application/json", + }, + }); + + return result !== undefined; + } + + async createToken(username: string, secret: string): Promise { + return await this.request(`/api/v1/users/${username}/tokens`, { + method: "POST", + headers: { + "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: this.tokenName, + scopes: ["write:user", "read:user"], + }), + }); + } +} diff --git a/app.ts b/app.ts index d86a42b..4ca9d3e 100644 --- a/app.ts +++ b/app.ts @@ -1,319 +1,229 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { Select } from "jsr:@cliffy/prompt@1.0.0-rc.7"; -import { promptSecret, parseArgs } from "jsr:@std/cli" - -const tokenStorage = new AsyncLocalStorage(); - -function printHelp() { - const helpText = `forgejo oauth2cli. interactive client credentials generator. - -Usage: app.ts [options] -Options: - -h, --help Show this help message - -v, --version Show version information`; - console.log(helpText); -} +import { Command } from "jsr:@cliffy/command@1.0.0-rc.7"; +import { Secret } from "jsr:@cliffy/prompt@1.0.0-rc.7"; +import { homedir } from "node:os"; +import { TokenApi } from "./api/token-api.ts"; +import { OAuth2Api } from "./api/oauth2-api.ts"; +import { CredentialManager } from "./util/credential-manager.ts"; +import { prettyOauth2Application, saveSecretKeys } from "./util/helpers.ts"; +// Constants that were previously in types.ts const URL_BASE = "https://git.monoid.top"; - -type Token = { - id: number, - name: string, - scopes: string[], - sha1: string, - token_last_eight: string, -} - -async function listTokens(username: string, secret: string) { - const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens`, { - method: "GET", - headers: { - "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - console.error("Failed to list tokens:", res.statusText); - console.error("Response:", await res.text()); - return; - } - const data = await res.json(); - if (data.error) { - console.error("Error listing tokens:", data.error); - return; - } - return data as Token[]; -} - -async function deleteToken(username: string, secret: string, tokenId: number) { - const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens/${tokenId}`, { - method: "DELETE", - headers: { - "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - console.error("Failed to delete token:", res.statusText); - console.error("Response:", await res.text()); - return; - } -} - -async function getToken(username: string, secret: string) { - // Basic auth - // TODO: two factor auth and webauthn support - const res = await fetch(`${URL_BASE}/api/v1/users/${username}/tokens`, { - method: "POST", - headers: { - "Authorization": `Basic ${btoa(`${username}:${secret}`)}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: "oauth2cli", - scopes: ["write:user", "read:user"], - }), - }); - if (!res.ok) { - console.error("Failed to create token:", res.statusText); - console.error("Response:", await res.text()); - return; - } - const data = await res.json(); - if (data.error) { - console.error("Error creating token:", data.error); - return; - } - return data as Token; -} - -type Oauth2Application = { - id: number, - name: string, - redirect_uris: string[], - client_id: string, - client_secret: string, - confidential_client: boolean, - /** - * @format date-time - */ - created: string, -} - -function prettyOauth2Application(app: Oauth2Application): string { - return (`ID: ${app.id} - Name: ${app.name} - Redirect URIs: ${app.redirect_uris.join(", ")} - Client ID: ${app.client_id} - Confidential Client: ${app.confidential_client ? "Yes" : "No"} - Created: ${new Date(app.created).toLocaleString()}`); -} - -async function createOauth2Application({ - confidential_client = false, - name, - redirect_uris, -}: { - confidential_client?: boolean, - name: string, - redirect_uris: string[], -}) { - const token = tokenStorage.getStore(); - if (!token) { - throw new Error("Token not found in storage."); - } - const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2`, { - method: "POST", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - confidential_client, - name, - redirect_uris, - }), - }); - if (!res.ok) { - console.error("Failed to create oauth2 application:", res.statusText); - console.error("Response:", await res.text()); - return; - } - const data = await res.json(); - if (data.error) { - console.error("Error creating oauth2 application:", data.error); - return; - } - return data as Oauth2Application; -} - -async function getOauth2Application() { - const token = tokenStorage.getStore(); - if (!token) { - throw new Error("Token not found in storage."); - } - const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2`, { - method: "GET", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - console.error("Failed to get oauth2 application:", res.statusText); - console.error("Response:", await res.text()); - return; - } - const data = await res.json(); - if (data.error) { - console.error("Error getting oauth2 application:", data.error); - return; - } - return data as Oauth2Application[]; -} - -async function deleteOauth2Application(id: number) { - const token = tokenStorage.getStore(); - if (!token) { - throw new Error("Token not found in storage."); - } - const res = await fetch(`${URL_BASE}/api/v1/user/applications/oauth2/${id}`, { - method: "DELETE", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - console.error("Failed to delete oauth2 application:", res.statusText); - console.error("Response:", await res.text()); - return; - } -} +const TOKEN_NAME = "oauth2cli"; +const CREDENTIALS_FILE = `${homedir()}/.oauth2cli-forgejo`; async function main() { - const args = parseArgs(Deno.args); - - if (args.help) { - printHelp(); - return; - } - - if (args.version) { - console.log("forgejo oauth2cli v0.1.0"); - return; - } - - // TODO: libsecret support (Linux), keychain support (macOS), wincred support (Windows) - - const username = await prompt("Enter your username: "); - if (!username) { - console.error("Username is required."); - return; - } - const secret = await promptSecret("Enter your secret: "); - if (!secret) { - console.error("Secret is required."); - return; - } - // check already created tokens - const tokens = await listTokens(username, secret); - if (!tokens) { - console.error("Failed to list tokens."); - return; - } - console.log("Tokens:", tokens.map(t => t.name)); - if (tokens.map(t=> t.name).includes("oauth2cli")) { - console.log("Token already created, deleting..."); - const token = tokens.find(t => t.name === "oauth2cli"); - if (!token) { - console.error("Failed to find token."); - return; - } - await deleteToken(username, secret, token.id); - } - console.log("Creating new token..."); - // create new token - const token = await getToken(username, secret); - if (!token) { - console.error("Failed to get token."); - return; - } - console.log("Token created successfully!"); - await tokenStorage.run(token.sha1 ,async () => { - while (true) { - const opt = await Select.prompt({ - message: "Select an option", - options: [ - { name: "Create new oauth2 application", value: "create" }, - { name: "Get existing oauth2 application", value: "get" }, - { name: "Delete oauth2 application", value: "delete" }, - { name: "List oauth2 applications", value: "list" }, - { name: "Exit", value: "exit" }, - ], - }); - switch (opt) { - case "create": { - const name = await prompt("Enter the name of the application: "); - if (!name) { - console.error("Name is required."); - break; - } - const redirect_uris = await prompt("Enter the redirect uris (comma separated): "); - if (!redirect_uris) { - console.error("Redirect uris are required."); - break; - } - const app = await createOauth2Application({ - name, - redirect_uris: redirect_uris.split(","), - }); - if (!app) { - console.error("Failed to create oauth2 application."); - break; - } - console.log("Oauth2 application created successfully!", app); - break; - } - case "get": { - const app = await getOauth2Application(); - if (!app) { - console.error("Failed to get oauth2 application."); - break; - } - console.log("Oauth2 applications:", app); - break; - } - case "delete": { - const id = await prompt("Enter the id of the application to delete: "); - if (!id) { - console.error("Id is required."); - break; - } - const appId = parseInt(id); - if (isNaN(appId)) { - console.error("Id must be a number."); - break; - } - await deleteOauth2Application(appId); - console.log("Oauth2 application deleted successfully!"); - break; - } - case "list": { - const app = await getOauth2Application(); - if (!app) { - console.error("Failed to get oauth2 application."); - break; - } - console.log("Oauth2 applications:", app.map(prettyOauth2Application).join("\n\n")); - break; - } - case "exit": - default: + const tokenApi = new TokenApi(URL_BASE, TOKEN_NAME); + const credentialManager = new CredentialManager(CREDENTIALS_FILE, TOKEN_NAME); + + try { + await new Command() + .name("forgejo-oauth2cli") + .version("0.1.0") + .description("Interactive client credentials generator for Forgejo.") + .command("login", "Authenticate with Forgejo interactively") + .action(async() => { + const username = await prompt("Enter your username: "); + if (!username) { + console.error("Username is required."); return; - } + } + + const secret = await Secret.prompt("Enter your secret: "); + if (!secret) { + console.error("Secret is required."); + return; + } + + // Check for existing tokens + const tokens = await tokenApi.listTokens(username, secret); + if (!tokens) { + return; + } + + // Delete existing token if found + const existingToken = tokens.find(t => t.name === TOKEN_NAME); + if (existingToken) { + console.log("Existing token found, replacing it..."); + await tokenApi.deleteToken(username, secret, existingToken.id); + } + + // Create new token + console.log("Creating new token..."); + const token = await tokenApi.createToken(username, secret); + if (!token) { + return; + } + + // Store credentials + await credentialManager.storeCredentials(username, token.sha1); + console.log("Login successful! Credentials stored securely."); + }) + .command("logout", "Delete token and credentials") + .action(async () => { + const username = await prompt("Enter your username: "); + if (!username) { + console.error("Username is required."); + return; + } + + const secret = await Secret.prompt("Enter your secret: "); + if (!secret) { + console.error("Secret is required."); + return; + } + + // Find and delete token + const tokens = await tokenApi.listTokens(username, secret); + if (!tokens) { + return; + } + + const existingToken = tokens.find(t => t.name === TOKEN_NAME); + if (existingToken) { + await tokenApi.deleteToken(username, secret, existingToken.id); + console.log("Token deleted from Forgejo."); + } + + // Clear stored credentials + await credentialManager.clearCredentials(username); + console.log("Logout successful! Credentials removed."); + }) + .command("list, ls", "List all OAuth2 applications") + .action(async () => { + const credentials = await credentialManager.getCredentials(); + if (!credentials) return; + + // Create OAuth2Api with token + const oauth2Api = new OAuth2Api(URL_BASE, credentials.token); + const apps = await oauth2Api.getOauth2Applications(); + if (!apps) return; + + if (apps.length === 0) { + console.log("No OAuth2 applications found."); + return; + } + + console.log("OAuth2 applications:"); + console.log(apps.map(prettyOauth2Application).join("\n\n")); + }) + .command("create", "Create new OAuth2 application") + .action(async () => { + const credentials = await credentialManager.getCredentials(); + if (!credentials) return; + + // Create OAuth2Api with token + const oauth2Api = new OAuth2Api(URL_BASE, credentials.token); + + const name = await prompt("Enter the name of the application: "); + if (!name) { + console.error("Name is required."); + return; + } + + const redirectUris = await prompt("Enter the redirect URIs (comma separated): "); + if (!redirectUris) { + console.error("Redirect URIs are required."); + return; + } + + const app = await oauth2Api.createOauth2Application({ + name, + redirect_uris: redirectUris.split(",").map(uri => uri.trim()), + }); + + if (!app) return; + + console.log("OAuth2 application created successfully!"); + console.log(prettyOauth2Application(app)); + + // Save secret keys + const path = await prompt("Enter the path to save the OAuth2 secret keys: "); + if (path) { + await saveSecretKeys(path, app.client_id, app.client_secret); + } + }) + .command("delete", "Delete OAuth2 application") + .action(async () => { + const credentials = await credentialManager.getCredentials(); + if (!credentials) return; + + // Create OAuth2Api with token + const oauth2Api = new OAuth2Api(URL_BASE, credentials.token); + + const id = await prompt("Enter the ID of the application to delete: "); + if (!id) { + console.error("ID is required."); + return; + } + + const appId = parseInt(id); + if (isNaN(appId)) { + console.error("ID must be a number."); + return; + } + + const success = await oauth2Api.deleteOauth2Application(appId); + if (success) { + console.log("OAuth2 application deleted successfully!"); + } + }) + .command("update", "Update OAuth2 application") + .action(async () => { + const credentials = await credentialManager.getCredentials(); + if (!credentials) return; + + // Create OAuth2Api with token + const oauth2Api = new OAuth2Api(URL_BASE, credentials.token); + + const id = await prompt("Enter the ID of the application to update: "); + if (!id) { + console.error("ID is required."); + return; + } + + const appId = parseInt(id); + if (isNaN(appId)) { + console.error("ID must be a number."); + return; + } + + const name = await prompt("Enter the new name of the application: "); + if (!name) { + console.error("Name is required."); + return; + } + + const redirectUris = await prompt("Enter the new redirect URIs (comma separated): "); + if (!redirectUris) { + console.error("Redirect URIs are required."); + return; + } + + const app = await oauth2Api.updateOauth2Application(appId, { + name, + redirect_uris: redirectUris.split(",").map(uri => uri.trim()), + }); + + if (!app) return; + + console.log("OAuth2 application updated successfully!"); + console.log(prettyOauth2Application(app)); + + // Save secret keys + const path = await prompt("Enter the path to save the OAuth2 secret keys: "); + if (path) { + await saveSecretKeys(path, app.client_id, app.client_secret); + } + }) + .parse(Deno.args); + } catch (error) { + if (error instanceof Error) { + console.error("An error occurred:", error.message); } - }) + else { + console.error("An unexpected error occurred:", error); + } + } } if (import.meta.main) { diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..76fe178 --- /dev/null +++ b/types.ts @@ -0,0 +1,24 @@ +export const URL_BASE = "https://git.monoid.top"; +export const TOKEN_NAME = "oauth2cli"; +export const CREDENTIALS_FILE = `${Deno.env.get("HOME") || ""}/.oauth2cli-forgejo`; + +export type Token = { + id: number, + name: string, + scopes: string[], + sha1: string, + token_last_eight: string, +} + +export type Oauth2Application = { + id: number, + name: string, + redirect_uris: string[], + client_id: string, + client_secret: string, + confidential_client: boolean, + /** + * @format date-time + */ + created: string, +} diff --git a/util/credential-manager.ts b/util/credential-manager.ts new file mode 100644 index 0000000..f1ec414 --- /dev/null +++ b/util/credential-manager.ts @@ -0,0 +1,53 @@ +import { Entry } from "npm:@napi-rs/keyring"; + +export class CredentialManager { + constructor( + private credentialsFile: string, + private tokenName: string + ) {} + + async storeCredentials(username: string, token: string): Promise { + // Store username + await Deno.writeTextFile(this.credentialsFile, username); + + // Store token in keyring + const entry = new Entry(this.tokenName, username); + if (entry.getPassword()) { + entry.deletePassword(); + } + entry.setPassword(token); + } + + async getCredentials(): Promise<{ username: string, token: string } | undefined> { + try { + const username = await Deno.readTextFile(this.credentialsFile); + const entry = new Entry(this.tokenName, username); + const token = entry.getPassword(); + + if (!token) { + console.error("No token found in keyring. Please login first."); + return undefined; + } + + return { username, token }; + } catch (error) { + console.error("No credentials found. Please login first."); + return undefined; + } + } + + async clearCredentials(username: string): Promise { + // Remove username file + try { + await Deno.remove(this.credentialsFile); + } catch (error) { + console.error("Failed to remove credentials file:", error); + } + + // Remove token from keyring + const entry = new Entry(this.tokenName, username); + if (entry.getPassword()) { + entry.deletePassword(); + } + } +} diff --git a/util/helpers.ts b/util/helpers.ts new file mode 100644 index 0000000..092b8a6 --- /dev/null +++ b/util/helpers.ts @@ -0,0 +1,16 @@ +import { Oauth2Application } from "../api/oauth2-api.ts"; + +export function prettyOauth2Application(app: Oauth2Application): string { + return (`ID: ${app.id} + Name: ${app.name} + Redirect URIs: ${app.redirect_uris.join(", ")} + Client ID: ${app.client_id} + Confidential Client: ${app.confidential_client ? "Yes" : "No"} + Created: ${new Date(app.created).toLocaleString()}`); +} + +export async function saveSecretKeys(path: string, clientId: string, clientSecret: string): Promise { + const secretKey = `FORGEJO_CLIENT_KEY=${clientId}\nFORGEJO_SECRET_KEY=${clientSecret}`; + await Deno.writeTextFile(path, secretKey, { append: true, create: true }); + console.log("OAuth2 secret keys saved successfully!"); +}