feat: add subtoken path auth for /u/{name}@{token} type/scripts/meta/run routes and coverage

This commit is contained in:
2026-02-25 13:49:32 +08:00
parent 07d5c1db52
commit e2fdb99c91
3 changed files with 119 additions and 0 deletions

View File

@@ -101,12 +101,23 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
get("/u/{subAuth}/type") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
call.respondText(tokenTypeJson(auth), contentType = ContentType.Application.Json)
}
get("/scripts") {
val auth = requireAuth(call, security) ?: return@get
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
get("/u/{subAuth}/scripts") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
val allow = visibleScriptsFor(auth)
call.respondText(renderScriptList(scriptsDir, allow), ContentType.Text.Plain)
}
get("/scripts/{script}") {
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
@@ -151,6 +162,26 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
)
}
get("/u/{subAuth}/meta/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
val name = call.parameters["script"]
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return@get
val script = resolveScriptFile(scriptsDir, name)
?: return@get call.respondText("invalid script name", status = HttpStatusCode.BadRequest)
if (!script.exists()) {
return@get call.respondText("script not found: ${script.name}", status = HttpStatusCode.NotFound)
}
val (metadata, source) = loadMetadata(script)
call.respondText(
metadataJson(name, metadata, source),
contentType = ContentType.Application.Json,
)
}
get("/run/{script}") {
val auth = requireAuth(call, security) ?: return@get
val name = call.parameters["script"]
@@ -161,6 +192,16 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
}
}
get("/u/{subAuth}/run/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@get
val name = call.parameters["script"]
?: return@get call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return@get
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = false)
}
}
post("/run/{script}") {
val auth = requireAuth(call, security) ?: return@post
val name = call.parameters["script"]
@@ -171,6 +212,16 @@ fun Application.webModule(scriptsDir: File, security: HostSecurity, runConcurren
}
}
post("/u/{subAuth}/run/{script}") {
val auth = requireSubTokenPathAuth(call, security) ?: return@post
val name = call.parameters["script"]
?: return@post call.respondText("missing route name", status = HttpStatusCode.BadRequest)
if (!requireScriptAccess(call, auth, name)) return@post
runConcurrencyLimiter.withPermit {
handleRunRequest(call, scriptsDir, consumeBody = true)
}
}
get("/subtokens") {
val auth = requireAuth(call, security) ?: return@get
if (!requireRoot(call, auth)) return@get
@@ -211,17 +262,22 @@ Usage:
Routes:
GET /health
GET /type
GET /u/{subtoken_name}@{subtoken}/type (subtoken path auth)
Authorization:
Authorization: Bearer <token>
or X-Host-Token: <token>
GET /scripts
GET /u/{subtoken_name}@{subtoken}/scripts (subtoken path auth)
GET /scripts/{script} (root only)
POST /scripts/{script} (root only)
PUT /scripts/{script} (root only)
DELETE /scripts/{script} (root only)
GET /meta/{script} (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/meta/{script}
GET /run/{script}?k=v (root or allowed subtoken)
GET /u/{subtoken_name}@{subtoken}/run/{script}?k=v
POST /run/{script}?k=v (root or allowed subtoken)
POST /u/{subtoken_name}@{subtoken}/run/{script}?k=v
GET /subtokens (root only)
GET /subtokens/{name} (root only)
POST /subtokens/{name} (root only, body: script names list)

View File

@@ -62,6 +62,13 @@ class SubTokenStore(
byName.values.firstOrNull { it.token == token }
}
fun findByNameAndToken(name: String, token: String): SubTokenRecord? =
synchronized(lock) {
ensureLoaded()
val record = byName[name] ?: return null
if (record.token == token) record else null
}
fun create(name: String, scripts: Set<String>): SubTokenRecord {
synchronized(lock) {
ensureLoaded()
@@ -198,6 +205,23 @@ private fun authenticateToken(call: ApplicationCall, security: HostSecurity): Au
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
}
private fun parseSubTokenPathCredential(raw: String?): Pair<String, String>? {
val value = raw?.trim().orEmpty()
if (value.isBlank()) return null
val at = value.indexOf('@')
if (at <= 0 || at == value.lastIndex) return null
val name = value.substring(0, at).trim()
val token = value.substring(at + 1).trim()
if (!name.matches(SCRIPT_NAME_REGEX) || token.isBlank()) return null
return name to token
}
fun authenticateSubTokenPath(pathCredential: String?, security: HostSecurity): AuthContext? {
val (name, token) = parseSubTokenPathCredential(pathCredential) ?: return null
val sub = security.subTokens.findByNameAndToken(name, token) ?: return null
return AuthContext(type = TokenType.SUB, subTokenName = sub.name, allowedScripts = sub.scripts)
}
suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthContext? {
val context = authenticateToken(call, security)
if (context != null) return context
@@ -207,6 +231,21 @@ suspend fun requireAuth(call: ApplicationCall, security: HostSecurity): AuthCont
return null
}
suspend fun requireSubTokenPathAuth(
call: ApplicationCall,
security: HostSecurity,
pathParamName: String = "subAuth",
): AuthContext? {
val context = authenticateSubTokenPath(call.parameters[pathParamName], security)
if (context != null) return context
call.respondText(
"unauthorized subtoken path, expected /u/<subtoken_name>@<subtoken>/...",
status = HttpStatusCode.Unauthorized,
contentType = ContentType.Text.Plain,
)
return null
}
suspend fun requireRoot(call: ApplicationCall, context: AuthContext): Boolean {
if (context.type == TokenType.ROOT) return true
call.respondText("forbidden: root token required", status = HttpStatusCode.Forbidden, contentType = ContentType.Text.Plain)

View File

@@ -159,6 +159,30 @@ class WebHostApiTest {
val listSubTokens = client.get("/subtokens") { bearer(token) }
assertEquals(HttpStatusCode.Forbidden, listSubTokens.status)
val typeByPath = client.get("/u/demo-sub@$token/type")
assertEquals(HttpStatusCode.OK, typeByPath.status)
assertTrue(typeByPath.bodyAsText().contains("\"tokenType\":\"sub\""))
val scriptsByPath = client.get("/u/demo-sub@$token/scripts")
assertEquals(HttpStatusCode.OK, scriptsByPath.status)
assertTrue(scriptsByPath.bodyAsText().contains("allowed"))
assertFalse(scriptsByPath.bodyAsText().contains("blocked"))
val metaByPathAllowed = client.get("/u/demo-sub@$token/meta/allowed")
assertEquals(HttpStatusCode.OK, metaByPathAllowed.status)
val metaByPathBlocked = client.get("/u/demo-sub@$token/meta/blocked")
assertEquals(HttpStatusCode.Forbidden, metaByPathBlocked.status)
val runByPathAllowed = client.get("/u/demo-sub@$token/run/allowed")
assertEquals(HttpStatusCode.OK, runByPathAllowed.status)
val runByPathBlocked = client.get("/u/demo-sub@$token/run/blocked")
assertEquals(HttpStatusCode.Forbidden, runByPathBlocked.status)
val invalidPathAuth = client.get("/u/demo-sub@invalid-token/scripts")
assertEquals(HttpStatusCode.Unauthorized, invalidPathAuth.status)
}
@Test