commit 9d735b1cf087d5603df81d5eb5537d6133169058 Author: monoid Date: Wed Apr 9 03:59:11 2025 +0900 init diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b943dbc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca35593 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Forgejo Oauth2 cli app + +This is a simple command line application that allows you to create a new OAuth2 application in Forgejo. diff --git a/app.ts b/app.ts new file mode 100644 index 0000000..d86a42b --- /dev/null +++ b/app.ts @@ -0,0 +1,321 @@ +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); +} + +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; + } +} + +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: + return; + } + } + }) +} + +if (import.meta.main) { + await main(); +}