Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
55 changes: 55 additions & 0 deletions server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export type User = {
personalRepo?: string
createdAt: string
activatedAt?: string
/**
* External OAuth provider id (from ExternalAuth.id). Set only for accounts
* created via the external-auth callback — these accounts have empty
* salt/hash and authenticate exclusively via SSO.
*/
oauthProvider?: string
/** Stable external user id returned by ExternalAuth.verify(). */
oauthId?: string
}

export type PublicUser = {
Expand Down Expand Up @@ -204,6 +212,53 @@ export async function deleteUser(id: string): Promise<boolean> {
return true
}

/**
* Find a user by their external OAuth identity (provider + oauthId pair).
* Returns null if no such user exists.
*/
export async function findUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
const f = await readUsersFile()
return f.users.find((u) => u.oauthProvider === provider && u.oauthId === oauthId) ?? null
}

/**
* Create an account linked to an external OAuth identity.
*
* Security model: empty salt/hash means password login is intentionally
* impossible for this account. Authentication is entirely delegated to the
* external provider's `verify()` function, which MUST validate token freshness
* and prevent replay attacks. loopat trusts the oauthId returned by verify()
* unconditionally.
*
* The account starts as role:"member", status:"active" — activation is not
* required because the external IdP has already authenticated the user.
*/
export async function createOAuthUser(input: {
id: string
oauthProvider: string
oauthId: string
email?: string
}): Promise<User> {
if (!isValidUsername(input.id)) throw new Error("invalid username (lowercase a-z0-9_- , 1-32 chars, leading alnum)")
const f = await readUsersFile()
if (f.users.some((u) => u.id === input.id)) throw new Error("username taken")
const now = new Date().toISOString()
const isFirst = f.users.length === 0
const user: User = {
id: input.id,
salt: "",
hash: "",
role: isFirst ? "admin" : "member",
status: "active",
oauthProvider: input.oauthProvider,
oauthId: input.oauthId,
createdAt: now,
activatedAt: now,
}
await writeUsersFile({ users: [...f.users, user] })
return user
}

/**
* Persist a user's personalRepo URL. Used when the user filled it in after
* registration (via the import dialog). Idempotent — no-op if the value is
Expand Down
60 changes: 60 additions & 0 deletions server/src/git-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,45 @@ export type OnboardingView =
/** External help links (e.g. where to register the key). */
help?: { label: string; url: string }[]
}
| {
/**
* Embed: the provider supplies an HTML string; loopat renders it as
* a same-origin srcdoc iframe. The iframe inherits the parent window's
* origin so relative /api fetch calls carry the session cookie
* (SameSite=Lax). The iframe MUST NOT have a sandbox attribute so
* cookies and scripts work.
*/
kind: "embed"
html: string
title?: string
}
}

/**
* External auth (SSO) declaration. A provider sets this to delegate login
* to an external identity provider. loopat provides the generic callback
* routing; it has zero knowledge of the provider's protocol details.
*
* Security contract: `verify()` MUST validate token freshness and prevent
* replay attacks — loopat trusts its result unconditionally.
*/
export type ExternalAuth = {
/** Identifier stored in users.json as `oauthProvider`. Must be stable. */
id: string
/** Text for the SSO login button (shown in AuthPage). No hardcoded names in core. */
label: string
/** URL path that the external IdP will redirect to after login. Must be unique. */
callbackPath: string
/** Query parameter name in the callback URL that carries the auth token. */
tokenParam: string
/** Build the URL to redirect the user to for authentication.
* `backUrl` = the full callback URL (origin + callbackPath). */
buildLoginUrl(backUrl: string): string
/** Verify the token received in the callback. Returns the user's stable
* external identity. Throw to signal invalid/expired tokens. */
verify(token: string): Promise<{ oauthId: string; username: string; email?: string }>
}

export interface GitHostProvider {
readonly id: string
readonly label: string
Expand All @@ -88,6 +125,13 @@ export interface GitHostProvider {
* (core stays platform-agnostic). */
readonly tokenHelp?: string

/**
* Optional external auth (SSO) configuration. When present, loopat exposes
* generic delegation routes (/api/auth/external/*) and a login button in
* AuthPage. loopat never knows the provider's name — label comes from here.
*/
readonly externalAuth?: ExternalAuth

/** Optional defaults the provider declares so loopat needs no config.json:
* the git host base URL and the default personal-repo name. A request may
* still override either; absent both, baseUrl falls back to the provider's
Expand Down Expand Up @@ -186,12 +230,16 @@ export interface GitHostProvider {
* ctx.vaultDir — `${repoDir}/.loopat/vaults/default` (encrypted)
* ctx.userId — the loopat user being set up
* ctx.login — their login on this platform
* ctx.token — the provisioning token used for /api/personal/github (A5).
* Passed through so the provider can do host-side registration
* (e.g. register an ssh public key). loopat does NOT interpret it.
*/
seedDefaults?(ctx: {
repoDir: string
vaultDir: string
userId: string
login: string
token?: string
}): Promise<void>
}

Expand All @@ -206,3 +254,15 @@ export function getProvider(id: string): GitHostProvider | undefined {
export function listProviders(): { id: string; label: string }[] {
return [...providers.values()].map((p) => ({ id: p.id, label: p.label }))
}

/**
* Return the ExternalAuth config declared by the first registered provider
* that has one. Returns null when no active provider declares externalAuth —
* in that case external-auth routes stay disabled (401 / 404).
*/
export function getExternalAuth(): ExternalAuth | null {
for (const p of providers.values()) {
if (p.externalAuth) return p.externalAuth
}
return null
}
160 changes: 157 additions & 3 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import { queryUserTokenUsage, queryWorkspaceTokenUsage, queryDailyTokenUsage, qu
import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
import { printBootstrapBanner, printReadyLine } from "./bootstrap"
import { resolveProvider } from "./providers"
import { resolveProvider, loadExtensionProviders } from "./providers"
import { ensureSandboxClaudeBinary } from "./claude-binary"
import { serveHostExec, hostExecSocketPath } from "./host-exec"
import {
Expand All @@ -83,7 +83,10 @@ import {
activateUser,
setUserRole,
deleteUser,
findUserByOAuth,
createOAuthUser,
} from "./auth"
import { getExternalAuth } from "./git-host"
import { getCookie } from "hono/cookie"

const execFileP = promisify(execFile)
Expand Down Expand Up @@ -409,6 +412,43 @@ app.get("/api/auth/me", async (c) => {
return c.json({ user: { id: user.id, role: user.role, status: user.status } })
})

// ── external auth (SSO delegation) ──
// These routes are zero-knowledge: loopat knows nothing about the provider's
// protocol. All SSO details (label, callbackPath, tokenParam, buildLoginUrl,
// verify) come from the active extension provider's `externalAuth` declaration.

/**
* GET /api/auth/external/status
* Returns whether an external auth provider is available and what label to
* show on the login button. No auth required — called from AuthPage before
* login.
*/
app.get("/api/auth/external/status", async (_c) => {
await loadExtensionProviders()
const ext = getExternalAuth()
if (!ext) return _c.json({ enabled: false })
const origin = publicBaseUrl(_c)
return _c.json({
enabled: true,
label: ext.label,
startUrl: `${origin}/api/auth/external/start`,
})
})

/**
* GET /api/auth/external/start
* Redirects the browser to the external IdP's login page.
* backUrl = origin + callbackPath so the IdP can redirect back.
*/
app.get("/api/auth/external/start", async (c) => {
await loadExtensionProviders()
const ext = getExternalAuth()
if (!ext) return c.json({ error: "no external auth provider configured" }, 404)
const origin = publicBaseUrl(c)
const backUrl = `${origin}${ext.callbackPath}`
return c.redirect(ext.buildLoginUrl(backUrl))
})

// ── admin (requireAdmin) ──

app.get("/api/admin/users", requireAdmin, async (c) => {
Expand Down Expand Up @@ -723,6 +763,51 @@ app.post("/api/settings/personal/value", requireAuth, async (c) => {
return c.json({ ok: true })
})

// Write a file into the user's vault mounts directory.
// Body: { path, contentBase64, vault? }
// `path` is relative (no leading slash, no `..` segments) and is placed under
// `vaults/<vault>/mounts/<path>`. Designed for onboarding flows (embed iframe)
// that need to provision config files or SSH keys into the vault before loops
// exist. Persists the same way as /value (commit + push).
app.post("/api/settings/personal/mount", requireAuth, async (c) => {
const userId = c.get("userId") as string
const body = await c.req.json().catch(() => ({}))
const relPath = typeof body.path === "string" ? body.path : ""
const contentBase64 = typeof body.contentBase64 === "string" ? body.contentBase64 : ""
const vault = typeof body.vault === "string" && body.vault ? body.vault : "default"
if (!VAULT_RE.test(vault)) return c.json({ error: "invalid vault" }, 400)
if (!relPath) return c.json({ error: "path required" }, 400)
// Security: reject absolute paths and any path component that is ".." to
// prevent escape outside the vault mounts directory.
if (relPath.startsWith("/") || relPath.split("/").some((s: string) => s === "..")) {
return c.json({ error: "path must be relative and must not contain '..'" }, 400)
}
if (!contentBase64) return c.json({ error: "contentBase64 required" }, 400)
let content: Buffer
try {
content = Buffer.from(contentBase64, "base64")
} catch {
return c.json({ error: "contentBase64 is not valid base64" }, 400)
}
const { personalVaultDir } = await import("./paths")
const { mkdir: mkdirP, writeFile: wf } = await import("node:fs/promises")
const { join: pj } = await import("node:path")
const mountsBase = pj(personalVaultDir(userId, vault), "mounts")
const destPath = pj(mountsBase, relPath)
// Final guard: confirm resolved path is still inside mountsBase.
if (!destPath.startsWith(mountsBase + "/") && destPath !== mountsBase) {
return c.json({ error: "resolved path escapes vault mounts directory" }, 400)
}
try {
await mkdirP(pj(destPath, ".."), { recursive: true })
await wf(destPath, content)
} catch (e: any) {
return c.json({ error: `write failed: ${e?.message ?? e}` }, 500)
}
await persistPersonalAfterVaultWrite(userId)
return c.json({ ok: true })
})

// ── MCP OAuth (auth required) ──
// loopat owns the OAuth dance entirely: discovery + DCR + auth code + PKCE
// + token exchange happen server-side. The resulting access token is written
Expand Down Expand Up @@ -865,13 +950,19 @@ app.post("/api/mcp-auth/start", requireAuth, async (c) => {
const userId = c.get("userId") as string
const body = await c.req.json().catch(() => ({}))
const serverName = typeof body.serverName === "string" ? body.serverName.trim() : ""
const loopId = typeof body.loopId === "string" ? body.loopId.trim() : ""
// loopId is now optional: callers in the onboarding stage (no loop yet) may
// omit it and pass serverConfig directly instead.
const loopId = typeof body.loopId === "string" ? body.loopId.trim() : undefined
// Optional caller-supplied server config (onboarding stage, no loop context).
const serverConfig = body.serverConfig && typeof body.serverConfig === "object" ? body.serverConfig : undefined
if (!serverName || !SERVER_NAME_RE.test(serverName)) return c.json({ error: "invalid serverName" }, 400)
if (!loopId) return c.json({ error: "loopId required" }, 400)
// Require at least one resolution source.
if (!loopId && !serverConfig) return c.json({ error: "loopId or serverConfig required" }, 400)
const r = await startMcpAuth({
user: userId,
serverName,
loopId,
serverConfig,
publicBaseUrl: publicBaseUrl(c),
})
if (!r.ok) return c.json({ error: r.error }, 400)
Expand Down Expand Up @@ -3195,6 +3286,69 @@ import { networkInterfaces } from "node:os"
const webDist = join(import.meta.dir, "..", "..", "web", "dist")
const indexHtml = join(webDist, "index.html")

/**
* External-auth callback interceptor — MUST sit before the SPA catch-all.
*
* Matches requests whose path equals the active provider's callbackPath (e.g.
* "/fixture-sso-callback"). /api/* and /ws/* are never intercepted here.
*
* Flow:
* 1. Read the token from query[tokenParam].
* 2. Call ext.verify(token) — throws on invalid/expired.
* 3. findUserByOAuth → existing user, or createOAuthUser (auto-active).
* 4. Issue a session cookie and 302 → "/".
*/
app.get("*", async (c, next) => {
const path = c.req.path
// Always pass through API and WebSocket routes.
if (path.startsWith("/api/") || path.startsWith("/ws/")) return next()

// Check whether the active provider has registered a callbackPath.
await loadExtensionProviders()
const ext = getExternalAuth()
if (ext && path === ext.callbackPath) {
const token = c.req.query(ext.tokenParam) ?? ""
if (!token) return c.redirect(`/?sso_error=missing_token`)
let identity: { oauthId: string; username: string; email?: string }
try {
identity = await ext.verify(token)
} catch (e: any) {
console.warn(`[ext-auth] verify failed: ${e?.message ?? e}`)
return c.redirect(`/?sso_error=verify_failed`)
}
// Find or create the OAuth user.
let user = await findUserByOAuth(ext.id, identity.oauthId)
if (!user) {
// Derive a username from the identity. Collisions: append a short suffix.
let candidateId = (identity.username ?? identity.oauthId)
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "_")
.slice(0, 28)
if (!/^[a-z0-9]/.test(candidateId)) candidateId = "u_" + candidateId
// Ensure uniqueness — try up to 10 numeric suffixes.
let finalId = candidateId
for (let i = 1; ; i++) {
try {
user = await createOAuthUser({ id: finalId, oauthProvider: ext.id, oauthId: identity.oauthId, email: identity.email })
break
} catch (e: any) {
if (e?.message === "username taken" && i < 10) {
finalId = `${candidateId}${i}`
} else {
console.warn(`[ext-auth] createOAuthUser failed: ${e?.message ?? e}`)
return c.redirect(`/?sso_error=create_user_failed`)
}
}
}
}
const sessionToken = createSession(user.id)
setSessionCookie(c, sessionToken)
return c.redirect("/")
}

return next()
})

app.get("*", async (c, next) => {
const path = c.req.path
// Don't interfere with API / WS routes
Expand Down
4 changes: 4 additions & 0 deletions server/src/loops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,10 @@ export async function setupPersonalViaProvider(opts: {
vaultDir: join(repoDir, ".loopat", "vaults", "default"),
userId: opts.userId,
login,
// A5: pass the provisioning token so the provider can do host-side
// registration (e.g. register an ssh public key). loopat does not
// interpret the token — it just threads it through.
token: opts.token,
})
: undefined
const imp = await importPersonalFromRepo(opts.userId, cloneUrl, opts.cryptKey, { name: login, email }, seed)
Expand Down
Loading