+ This page serves as the OAuth client_id for the IndieAuth sign-in flow.
+ After authorizing, you will be redirected back to Obsidian automatically.
+
+
+
diff --git a/src/IndieAuth.ts b/src/IndieAuth.ts
new file mode 100644
index 0000000..877c0d8
--- /dev/null
+++ b/src/IndieAuth.ts
@@ -0,0 +1,231 @@
+/**
+ * IndieAuth.ts — IndieAuth PKCE sign-in flow for obsidian-micropub
+ *
+ * Why no local HTTP server:
+ * IndieKit (and most IndieAuth servers) fetch the client_id URL server-side
+ * to retrieve app metadata. A local 127.0.0.1 address is unreachable from a
+ * remote server, so that approach always fails with "fetch failed".
+ *
+ * The solution — GitHub Pages relay:
+ * client_id = https://svemagie.github.io/obsidian-micropub/
+ * redirect_uri = https://svemagie.github.io/obsidian-micropub/callback
+ *
+ * Both are on the same host → IndieKit's host-matching check passes ✓
+ * The callback page is a static HTML file that immediately redirects to
+ * obsidian://micropub-auth?code=CODE&state=STATE
+ * Obsidian's protocol handler (registered in main.ts) receives the code.
+ *
+ * Flow:
+ * 1. Discover authorization_endpoint + token_endpoint from site HTML
+ * 2. Generate PKCE code_verifier + code_challenge (SHA-256)
+ * 3. Open browser → user's IndieAuth login page
+ * 4. User logs in → server redirects to GitHub Pages callback
+ * 5. Callback page redirects to obsidian://micropub-auth?code=...
+ * 6. Plugin protocol handler resolves the pending Promise
+ * 7. Exchange code for token at token_endpoint
+ */
+
+import * as crypto from "crypto";
+import { requestUrl } from "obsidian";
+
+export const CLIENT_ID = "https://svemagie.github.io/obsidian-micropub/";
+export const REDIRECT_URI = "https://svemagie.github.io/obsidian-micropub/callback";
+
+const SCOPE = "create update media";
+const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
+
+export interface IndieAuthResult {
+ accessToken: string;
+ scope: string;
+ /** Canonical "me" URL returned by the token endpoint */
+ me: string;
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ micropubEndpoint?: string;
+ mediaEndpoint?: string;
+}
+
+export interface DiscoveredEndpoints {
+ authorizationEndpoint: string;
+ tokenEndpoint: string;
+ micropubEndpoint?: string;
+}
+
+/** Pending callback set by main.ts protocol handler */
+let pendingCallback:
+ | { resolve: (params: Record) => void; state: string }
+ | null = null;
+
+/**
+ * Called by the Obsidian protocol handler in main.ts when
+ * obsidian://micropub-auth is opened by the browser.
+ */
+export function handleProtocolCallback(params: Record): void {
+ if (!pendingCallback) return;
+
+ const { resolve, state: expectedState } = pendingCallback;
+ pendingCallback = null;
+ resolve(params); // let signIn() validate state + extract code
+}
+
+export class IndieAuth {
+ // ── Public API ────────────────────────────────────────────────────────────
+
+ /**
+ * Discover IndieAuth + Micropub endpoint URLs from the site's home page
+ * by reading tags in the HTML .
+ */
+ static async discoverEndpoints(siteUrl: string): Promise {
+ const resp = await requestUrl({ url: siteUrl, method: "GET" });
+ const html = resp.text;
+
+ const authorizationEndpoint = IndieAuth.extractLinkRel(html, "authorization_endpoint");
+ const tokenEndpoint = IndieAuth.extractLinkRel(html, "token_endpoint");
+ const micropubEndpoint = IndieAuth.extractLinkRel(html, "micropub");
+
+ if (!authorizationEndpoint) {
+ throw new Error(
+ `No found at ${siteUrl}. ` +
+ "Make sure Indiekit is running and SITE_URL is set correctly.",
+ );
+ }
+ if (!tokenEndpoint) {
+ throw new Error(`No found at ${siteUrl}.`);
+ }
+
+ return { authorizationEndpoint, tokenEndpoint, micropubEndpoint };
+ }
+
+ /**
+ * Run the full IndieAuth PKCE sign-in flow.
+ *
+ * Opens the browser at the user's IndieAuth login page. After login the
+ * browser is redirected to the GitHub Pages callback, which triggers
+ * the obsidian://micropub-auth protocol, which resolves the Promise here.
+ *
+ * Requires handleProtocolCallback() to be wired up in main.ts via
+ * this.registerObsidianProtocolHandler("micropub-auth", handleProtocolCallback)
+ */
+ static async signIn(siteUrl: string): Promise {
+ // 1. Discover endpoints
+ const { authorizationEndpoint, tokenEndpoint, micropubEndpoint } =
+ await IndieAuth.discoverEndpoints(siteUrl);
+
+ // 2. Generate PKCE + state
+ const state = IndieAuth.base64url(crypto.randomBytes(16));
+ const codeVerifier = IndieAuth.base64url(crypto.randomBytes(64));
+ const codeChallenge = IndieAuth.base64url(
+ crypto.createHash("sha256").update(codeVerifier).digest(),
+ );
+
+ // 3. Register pending callback — resolved by handleProtocolCallback()
+ const callbackPromise = new Promise>(
+ (resolve, reject) => {
+ const timeout = setTimeout(() => {
+ pendingCallback = null;
+ reject(new Error("Sign-in timed out (5 min). Please try again."));
+ }, AUTH_TIMEOUT_MS);
+
+ pendingCallback = {
+ state,
+ resolve: (params) => {
+ clearTimeout(timeout);
+ resolve(params);
+ },
+ };
+ },
+ );
+
+ // 4. Build the authorization URL and open the browser
+ const authUrl = new URL(authorizationEndpoint);
+ authUrl.searchParams.set("response_type", "code");
+ authUrl.searchParams.set("client_id", CLIENT_ID);
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
+ authUrl.searchParams.set("state", state);
+ authUrl.searchParams.set("code_challenge", codeChallenge);
+ authUrl.searchParams.set("code_challenge_method","S256");
+ authUrl.searchParams.set("scope", SCOPE);
+ authUrl.searchParams.set("me", siteUrl);
+
+ window.open(authUrl.toString());
+
+ // 5. Wait for obsidian://micropub-auth to be called
+ const callbackParams = await callbackPromise;
+
+ // 6. Validate state (CSRF protection)
+ if (callbackParams.state !== state) {
+ throw new Error("State mismatch — possible CSRF attack. Please try again.");
+ }
+
+ const code = callbackParams.code;
+ if (!code) {
+ throw new Error(
+ callbackParams.error_description ??
+ callbackParams.error ??
+ "No authorization code received.",
+ );
+ }
+
+ // 7. Exchange code for token
+ const tokenResp = await requestUrl({
+ url: tokenEndpoint,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI,
+ code_verifier: codeVerifier,
+ }).toString(),
+ throw: false,
+ });
+
+ const data = tokenResp.json as {
+ access_token?: string;
+ scope?: string;
+ me?: string;
+ error?: string;
+ error_description?: string;
+ };
+
+ if (!data.access_token) {
+ throw new Error(
+ data.error_description ??
+ data.error ??
+ `Token exchange failed (HTTP ${tokenResp.status})`,
+ );
+ }
+
+ return {
+ accessToken: data.access_token,
+ scope: data.scope ?? SCOPE,
+ me: data.me ?? siteUrl,
+ authorizationEndpoint,
+ tokenEndpoint,
+ micropubEndpoint,
+ };
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private static base64url(buf: Buffer): string {
+ return buf.toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+ }
+
+ static extractLinkRel(html: string, rel: string): string | undefined {
+ const re = new RegExp(
+ `]+rel=["'][^"']*\\b${rel}\\b[^"']*["'][^>]+href=["']([^"']+)["']` +
+ `|]+href=["']([^"']+)["'][^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`,
+ "i",
+ );
+ const m = html.match(re);
+ return m?.[1] ?? m?.[2];
+ }
+}
diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts
index 74e83c0..94cc9d3 100644
--- a/src/SettingsTab.ts
+++ b/src/SettingsTab.ts
@@ -1,10 +1,20 @@
/**
* SettingsTab.ts — Obsidian settings UI for obsidian-micropub
+ *
+ * Authentication section works like iA Writer:
+ * 1. User enters their site URL
+ * 2. Clicks "Sign in" — browser opens at their IndieAuth login page
+ * 3. They log in with their blog password
+ * 4. Browser redirects back; plugin receives the token automatically
+ * 5. Settings show "Signed in as " + a Sign Out button
+ *
+ * Advanced users can still paste a token manually if they prefer.
*/
import { App, Notice, PluginSettingTab, Setting } from "obsidian";
import type MicropubPlugin from "./main";
import { MicropubClient } from "./MicropubClient";
+import { IndieAuth } from "./IndieAuth";
export class MicropubSettingsTab extends PluginSettingTab {
constructor(
@@ -20,66 +30,27 @@ export class MicropubSettingsTab extends PluginSettingTab {
containerEl.createEl("h2", { text: "Micropub Publisher" });
- // ── Endpoint discovery ───────────────────────────────────────────────
- containerEl.createEl("h3", { text: "Endpoint Configuration" });
+ // ── Site URL + Sign In ───────────────────────────────────────────────
+ containerEl.createEl("h3", { text: "Account" });
- new Setting(containerEl)
- .setName("Site URL")
- .setDesc(
- "Your site's home page. Used to auto-discover Micropub and token endpoints " +
- "from headers.",
- )
- .addText((text) =>
- text
- .setPlaceholder("https://example.com")
- .setValue(this.plugin.settings.siteUrl)
- .onChange(async (value) => {
- this.plugin.settings.siteUrl = value.trim();
- await this.plugin.saveSettings();
- }),
- )
- .addButton((btn) =>
- btn
- .setButtonText("Discover")
- .setCta()
- .onClick(async () => {
- if (!this.plugin.settings.siteUrl) {
- new Notice("Enter a site URL first.");
- return;
- }
- btn.setDisabled(true);
- btn.setButtonText("Discovering…");
- try {
- const client = new MicropubClient(
- () => this.plugin.settings.micropubEndpoint,
- () => this.plugin.settings.mediaEndpoint,
- () => this.plugin.settings.accessToken,
- );
- const discovered = await client.discoverEndpoints(
- this.plugin.settings.siteUrl,
- );
- if (discovered.micropubEndpoint) {
- this.plugin.settings.micropubEndpoint =
- discovered.micropubEndpoint;
- }
- if (discovered.mediaEndpoint) {
- this.plugin.settings.mediaEndpoint = discovered.mediaEndpoint;
- }
- await this.plugin.saveSettings();
- this.display(); // Refresh UI
- new Notice("✅ Endpoints discovered!");
- } catch (err: unknown) {
- new Notice(`Discovery failed: ${String(err)}`);
- } finally {
- btn.setDisabled(false);
- btn.setButtonText("Discover");
- }
- }),
- );
+ // Show current sign-in status
+ if (this.plugin.settings.me && this.plugin.settings.accessToken) {
+ this.renderSignedIn(containerEl);
+ } else {
+ this.renderSignedOut(containerEl);
+ }
+
+ // ── Endpoints (collapsed / advanced) ────────────────────────────────
+ containerEl.createEl("h3", { text: "Endpoints" });
+
+ containerEl.createEl("p", {
+ text: "These are filled automatically when you sign in. Only edit them manually if your server uses non-standard paths.",
+ cls: "setting-item-description",
+ });
new Setting(containerEl)
.setName("Micropub endpoint")
- .setDesc("e.g. https://example.com/micropub")
+ .setDesc("e.g. https://blog.giersig.eu/micropub")
.addText((text) =>
text
.setPlaceholder("https://example.com/micropub")
@@ -92,9 +63,7 @@ export class MicropubSettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Media endpoint")
- .setDesc(
- "Leave blank to discover automatically from the Micropub config response.",
- )
+ .setDesc("For image uploads. Auto-discovered if blank.")
.addText((text) =>
text
.setPlaceholder("https://example.com/micropub/media")
@@ -105,52 +74,6 @@ export class MicropubSettingsTab extends PluginSettingTab {
}),
);
- // ── Authentication ───────────────────────────────────────────────────
- containerEl.createEl("h3", { text: "Authentication" });
-
- new Setting(containerEl)
- .setName("Access token")
- .setDesc(
- "Bearer token from your site's IndieAuth token endpoint or admin panel.",
- )
- .addText((text) => {
- text
- .setPlaceholder("your-bearer-token")
- .setValue(this.plugin.settings.accessToken)
- .onChange(async (value) => {
- this.plugin.settings.accessToken = value.trim();
- await this.plugin.saveSettings();
- });
- text.inputEl.type = "password";
- })
- .addButton((btn) =>
- btn
- .setButtonText("Verify")
- .onClick(async () => {
- if (
- !this.plugin.settings.micropubEndpoint ||
- !this.plugin.settings.accessToken
- ) {
- new Notice("Set endpoint and token first.");
- return;
- }
- btn.setDisabled(true);
- try {
- const client = new MicropubClient(
- () => this.plugin.settings.micropubEndpoint,
- () => this.plugin.settings.mediaEndpoint,
- () => this.plugin.settings.accessToken,
- );
- await client.fetchConfig();
- new Notice("✅ Token is valid!");
- } catch (err: unknown) {
- new Notice(`Auth check failed: ${String(err)}`);
- } finally {
- btn.setDisabled(false);
- }
- }),
- );
-
// ── Publish behaviour ────────────────────────────────────────────────
containerEl.createEl("h3", { text: "Publish Behaviour" });
@@ -175,8 +98,8 @@ export class MicropubSettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Write URL back to note")
.setDesc(
- "After publishing, store the returned post URL as `mp-url` in the note's " +
- "frontmatter. Subsequent publishes will use this URL to update the post.",
+ "After publishing, store the post URL as `mp-url` in frontmatter. " +
+ "Subsequent publishes will update the existing post instead of creating a new one.",
)
.addToggle((toggle) =>
toggle
@@ -193,9 +116,8 @@ export class MicropubSettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName("Map #garden/* tags to gardenStage")
.setDesc(
- "When enabled, Obsidian tags like #garden/plant are converted to a " +
- "`garden-stage: plant` Micropub property. The Eleventy blog theme renders " +
- "these as growth stage badges and groups posts in the /garden/ index.",
+ "Obsidian tags like #garden/plant become a `garden-stage: plant` Micropub " +
+ "property. The blog renders these as growth stage badges at /garden/.",
)
.addToggle((toggle) =>
toggle
@@ -207,8 +129,188 @@ export class MicropubSettingsTab extends PluginSettingTab {
);
containerEl.createEl("p", {
- text: "Supported stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
+ text: "Stages: plant 🌱 · cultivate 🌿 · question ❓ · repot 🪴 · revitalize ✨ · revisit 🔄",
cls: "setting-item-description",
});
}
+
+ // ── Signed-out state ─────────────────────────────────────────────────────
+
+ private renderSignedOut(containerEl: HTMLElement): void {
+ // Site URL input + Sign In button on the same row
+ new Setting(containerEl)
+ .setName("Site URL")
+ .setDesc(
+ "Your site's home page. Clicking Sign in opens your blog's login page " +
+ "in the browser — the same flow iA Writer uses.",
+ )
+ .addText((text) =>
+ text
+ .setPlaceholder("https://blog.giersig.eu")
+ .setValue(this.plugin.settings.siteUrl)
+ .onChange(async (value) => {
+ this.plugin.settings.siteUrl = value.trim();
+ await this.plugin.saveSettings();
+ }),
+ )
+ .addButton((btn) => {
+ btn
+ .setButtonText("Sign in")
+ .setCta()
+ .onClick(async () => {
+ const siteUrl = this.plugin.settings.siteUrl.trim();
+ if (!siteUrl) {
+ new Notice("Enter your site URL first.");
+ return;
+ }
+
+ btn.setDisabled(true);
+ btn.setButtonText("Opening browser…");
+
+ try {
+ const result = await IndieAuth.signIn(siteUrl);
+
+ // Save everything returned by the auth flow
+ this.plugin.settings.accessToken = result.accessToken;
+ this.plugin.settings.me = result.me;
+ this.plugin.settings.authorizationEndpoint = result.authorizationEndpoint;
+ this.plugin.settings.tokenEndpoint = result.tokenEndpoint;
+ if (result.micropubEndpoint) {
+ this.plugin.settings.micropubEndpoint = result.micropubEndpoint;
+ }
+ if (result.mediaEndpoint) {
+ this.plugin.settings.mediaEndpoint = result.mediaEndpoint;
+ }
+
+ await this.plugin.saveSettings();
+
+ // Try to fetch the Micropub config to pick up media endpoint
+ if (!this.plugin.settings.mediaEndpoint) {
+ try {
+ const client = new MicropubClient(
+ () => this.plugin.settings.micropubEndpoint,
+ () => this.plugin.settings.mediaEndpoint,
+ () => this.plugin.settings.accessToken,
+ );
+ const cfg = await client.fetchConfig();
+ if (cfg["media-endpoint"]) {
+ this.plugin.settings.mediaEndpoint = cfg["media-endpoint"];
+ await this.plugin.saveSettings();
+ }
+ } catch {
+ // Non-fatal
+ }
+ }
+
+ new Notice(`✅ Signed in as ${result.me}`);
+ this.display(); // Refresh to show signed-in state
+ } catch (err: unknown) {
+ new Notice(`Sign-in failed: ${String(err)}`, 8000);
+ btn.setDisabled(false);
+ btn.setButtonText("Sign in");
+ }
+ });
+ });
+
+ // Divider + manual token fallback (collapsed by default)
+ const details = containerEl.createEl("details");
+ details.createEl("summary", {
+ text: "Or paste a token manually",
+ cls: "setting-item-description",
+ });
+ details.style.marginTop = "8px";
+ details.style.marginBottom = "8px";
+
+ new Setting(details)
+ .setName("Access token")
+ .setDesc("Bearer token from your Indiekit admin panel.")
+ .addText((text) => {
+ text
+ .setPlaceholder("your-bearer-token")
+ .setValue(this.plugin.settings.accessToken)
+ .onChange(async (value) => {
+ this.plugin.settings.accessToken = value.trim();
+ await this.plugin.saveSettings();
+ });
+ text.inputEl.type = "password";
+ })
+ .addButton((btn) =>
+ btn.setButtonText("Verify").onClick(async () => {
+ if (
+ !this.plugin.settings.micropubEndpoint ||
+ !this.plugin.settings.accessToken
+ ) {
+ new Notice("Set the Micropub endpoint and token first.");
+ return;
+ }
+ btn.setDisabled(true);
+ try {
+ const client = new MicropubClient(
+ () => this.plugin.settings.micropubEndpoint,
+ () => this.plugin.settings.mediaEndpoint,
+ () => this.plugin.settings.accessToken,
+ );
+ await client.fetchConfig();
+ new Notice("✅ Token is valid!");
+ } catch (err: unknown) {
+ new Notice(`Token check failed: ${String(err)}`);
+ } finally {
+ btn.setDisabled(false);
+ }
+ }),
+ );
+ }
+
+ // ── Signed-in state ──────────────────────────────────────────────────────
+
+ private renderSignedIn(containerEl: HTMLElement): void {
+ const me = this.plugin.settings.me;
+
+ // Avatar + "Signed in as" banner
+ const banner = containerEl.createDiv({
+ cls: "micropub-auth-banner",
+ });
+ banner.style.cssText =
+ "display:flex;align-items:center;gap:12px;padding:12px 16px;" +
+ "border:1px solid var(--background-modifier-border);" +
+ "border-radius:8px;margin-bottom:16px;background:var(--background-secondary);";
+
+ const icon = banner.createDiv();
+ icon.style.cssText =
+ "width:40px;height:40px;border-radius:50%;background:var(--interactive-accent);" +
+ "display:flex;align-items:center;justify-content:center;" +
+ "font-size:1.2rem;flex-shrink:0;";
+ icon.textContent = "🌐";
+
+ const info = banner.createDiv();
+ info.createEl("div", {
+ text: "Signed in",
+ attr: { style: "font-size:.75rem;color:var(--text-muted);margin-bottom:2px" },
+ });
+ info.createEl("div", {
+ text: me,
+ attr: { style: "font-weight:500;word-break:break-all" },
+ });
+
+ new Setting(containerEl)
+ .setName("Site URL")
+ .addText((text) =>
+ text
+ .setValue(this.plugin.settings.siteUrl)
+ .setDisabled(true),
+ )
+ .addButton((btn) =>
+ btn
+ .setButtonText("Sign out")
+ .setWarning()
+ .onClick(async () => {
+ this.plugin.settings.accessToken = "";
+ this.plugin.settings.me = "";
+ this.plugin.settings.authorizationEndpoint = "";
+ this.plugin.settings.tokenEndpoint = "";
+ await this.plugin.saveSettings();
+ this.display();
+ }),
+ );
+ }
}
diff --git a/src/main.ts b/src/main.ts
index 734e489..2420bb8 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -19,6 +19,7 @@ import { Notice, Plugin, TFile } from "obsidian";
import { DEFAULT_SETTINGS, type MicropubSettings } from "./types";
import { MicropubSettingsTab } from "./SettingsTab";
import { Publisher } from "./Publisher";
+import { handleProtocolCallback } from "./IndieAuth";
export default class MicropubPlugin extends Plugin {
settings!: MicropubSettings;
@@ -55,6 +56,14 @@ export default class MicropubPlugin extends Plugin {
},
});
+ // ── IndieAuth protocol handler ────────────────────────────────────────
+ // Receives obsidian://micropub-auth?code=...&state=... after the user
+ // approves on their IndieAuth login page. The GitHub Pages callback page
+ // at svemagie.github.io/obsidian-micropub/callback redirects here.
+ this.registerObsidianProtocolHandler("micropub-auth", (params) => {
+ handleProtocolCallback(params as Record);
+ });
+
// ── Settings tab ─────────────────────────────────────────────────────
this.addSettingTab(new MicropubSettingsTab(this.app, this));
diff --git a/src/types.ts b/src/types.ts
index cf9c287..f56ac52 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -32,9 +32,27 @@ export interface MicropubSettings {
*/
autoDiscover: boolean;
- /** Your site's homepage URL — used for endpoint discovery. */
+ /** Your site's homepage URL — used for endpoint discovery and IndieAuth. */
siteUrl: string;
+ /**
+ * The authorization_endpoint discovered from the site.
+ * Populated automatically by the IndieAuth sign-in flow.
+ */
+ authorizationEndpoint: string;
+
+ /**
+ * The token_endpoint discovered from the site.
+ * Populated automatically by the IndieAuth sign-in flow.
+ */
+ tokenEndpoint: string;
+
+ /**
+ * The canonical "me" URL returned by the token endpoint after sign-in.
+ * Used to show who is currently logged in.
+ */
+ me: string;
+
/**
* When true, after a successful publish the post URL returned by the server
* is written back to the note's frontmatter as `mp-url`.
@@ -59,6 +77,9 @@ export const DEFAULT_SETTINGS: MicropubSettings = {
defaultSyndicateTo: [],
autoDiscover: false,
siteUrl: "",
+ authorizationEndpoint: "",
+ tokenEndpoint: "",
+ me: "",
writeUrlToFrontmatter: true,
mapGardenTags: true,
defaultVisibility: "public",