Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

## [v0.2.1] - 2026-06-13

A module can now read its own **trusted app id** on every guarded surface — including Public routes, which previously had no identity at all.

### Added
- **`ms.AppID(ctx) string`** — the inbound twin of `ms.WithAppID`. Returns the app id from the request context's auth identity (`""` when none is set). This is the single **unspoofable** way a handler reads its own app: the SDK promotes the platform's trusted, dispatch-injected app id into the identity before the handler runs. Read this instead of pulling an app id off request data (query/body/path), which the caller controls and can forge.

### Changed
- **The proxy guard (`auth.RequireProxy`) now promotes trusted app identity on its success path.** After the platform token validates — which proves the `X-MS-*` headers were injected by dispatch, not client-forged — the guard sets `auth.Identity` (`AppID`/`UserID`/`AppRole`) from those headers before the handler runs. This closes a gap on **Public** routes: they mount only the proxy guard (not `PlatformAuth`), so `auth.Get(ctx).AppID` was always empty there and a module could not read its own app. Promotion never happens on a path that has not validated the token (standalone/inert and rejected requests don't promote), and never clobbers an identity already set (e.g. Lambda's `InjectResources`). Mirrors the prod-Lambda asymmetry that `runtime.InjectResources` already closed.

## [v0.2.0] - 2026-05-06

Phase 2 — module identity, prefix-aware schema resolution, and the cross-module data-routing contract. **Trust model: app owner is the trust root** for cross-module reads. The contributor declares nothing about who can read; the consumer declares what it wants from each dep; the catalog surfaces the pairing to the app owner at install time. Read-only by design — `GRANT SELECT` only, never write. Cross-module *writes* go through events or internal HTTP.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.2.1
55 changes: 44 additions & 11 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,21 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler {

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Lambda: headers already stripped + identity injected from payload.
// Lambda: headers already stripped + identity injected from payload
// (runtime.InjectResources). The payload is the trust boundary, so
// pass through WITHOUT promoting from headers — there are none to
// read, and the preset identity must not be touched.
if inLambda {
next.ServeHTTP(w, r)
return
}

expected, header, configured := readSecret()

// No token SOURCE configured: inert (standalone unit tests).
// No token SOURCE configured: inert (standalone unit tests). DO NOT
// promote identity here — nothing validated that the X-MS-* headers
// came from dispatch, so trusting them would let a direct caller
// forge an app id. The surface is simply open (local dev / tests).
if !configured {
next.ServeHTTP(w, r)
return
Expand All @@ -288,27 +294,54 @@ func requireProxy(inLambda bool) func(http.Handler) http.Handler {
// reject rather than silently pass every request through.
if expected == "" {
log.Printf("mirrorstack: proxy guard rejected (token source configured but unreadable) from %s %s", r.RemoteAddr, r.URL.Path)
httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{
Error: "request did not come through the platform proxy",
Code: CodeNotProxied,
})
rejectNotProxied(w)
return
}

token := r.Header.Get(header)
if !constantTimeEqual(token, expected) {
log.Printf("mirrorstack: proxy guard rejected (token mismatch, header_present=%v) from %s %s", token != "", r.RemoteAddr, r.URL.Path)
httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{
Error: "request did not come through the platform proxy",
Code: CodeNotProxied,
})
rejectNotProxied(w)
return
}
next.ServeHTTP(w, r)

// SUCCESS PATH ONLY. The platform token validated, which proves the
// X-MS-* identity headers were injected by dispatch (the browser
// never holds the token, so a direct caller cannot forge a request
// that reaches here). Promote those now-trusted headers to
// auth.Identity so a Public/Platform handler can read its app via
// auth.Get(ctx).AppID — the single unspoofable source of app id.
//
// CRITICAL ORDERING: this MUST run only after the token check above
// passes. Promoting before validation would trust spoofable headers.
//
// Don't clobber an identity already on the context: if something
// upstream (e.g. PlatformAuth on the platform surface, or a future
// authorizer) already set one, that wins.
ctx := r.Context()
if Get(ctx) == nil {
ctx = Set(ctx, Identity{
UserID: r.Header.Get(HeaderUserID),
AppID: r.Header.Get(HeaderAppID),
AppRole: r.Header.Get(HeaderAppRole),
})
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

// rejectNotProxied writes the standard 403 not_proxied response. Both failure
// paths in requireProxy (unreadable token source and token mismatch) produce
// the same JSON body; the log call before each site carries the distinct
// diagnostic. (Supersedes the dedup in PR #118.)
func rejectNotProxied(w http.ResponseWriter) {
httputil.JSON(w, http.StatusForbidden, httputil.ErrorResponse{
Error: "request did not come through the platform proxy",
Code: CodeNotProxied,
})
}

// secretReader returns the current secret, which header carries it, and
// whether a secret SOURCE is configured at all. The file-backed variant
// re-reads on every call so a refreshed token (CLI reconnect) is picked up
Expand Down
118 changes: 118 additions & 0 deletions auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,3 +663,121 @@ func TestRequireProxy_TokenFile_ReadError_NotMistakenForUnconfigured(t *testing.
t.Fatal("SecretConfigured() must be true when MS_PLATFORM_TOKEN_FILE is set even if unreadable")
}
}

// --- RequireProxy identity promotion (Public-route trusted app id) ---

func TestRequireProxy_ValidToken_PromotesIdentity(t *testing.T) {
// The whole point of the #236 redesign: on a Public route, after the proxy
// token validates, the dispatch-injected X-MS-* headers are promoted to
// auth.Identity so the handler can read its TRUSTED app id via auth.Get —
// PlatformAuth doesn't run on Public, so without this promotion AppID is
// empty there.
t.Setenv("MS_PLATFORM_TOKEN", "real-token")
var seen *Identity
handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = Get(r.Context())
}))

req := httptest.NewRequest("GET", "/public/me", nil)
req.Header.Set(HeaderPlatformToken, "real-token")
req.Header.Set(HeaderUserID, "u-pub-1")
req.Header.Set(HeaderAppID, "a-pub-2")
req.Header.Set(HeaderAppRole, RoleViewer)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for valid proxied request, got %d", rec.Code)
}
if seen == nil {
t.Fatal("expected promoted Identity on the context; got nil")
}
if seen.AppID != "a-pub-2" {
t.Errorf("AppID = %q, want a-pub-2 (the trusted, dispatch-injected app id)", seen.AppID)
}
if seen.UserID != "u-pub-1" || seen.AppRole != RoleViewer {
t.Errorf("identity mismatch: got %+v", seen)
}
}

func TestRequireProxy_RejectedRequest_NeverPromotes(t *testing.T) {
// A request rejected by the guard (no/wrong token) must NEVER reach the
// handler, so no identity is ever promoted from its (spoofable) headers.
t.Setenv("MS_PLATFORM_TOKEN", "real-token")
reached := false
handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
reached = true
}))

req := httptest.NewRequest("GET", "/public/me", nil)
// Attacker omits the token but tries to assert an app id directly.
req.Header.Set(HeaderAppID, "spoofed-app")
req.Header.Set(HeaderUserID, "spoofed-user")
req.Header.Set(HeaderAppRole, RoleAdmin)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403, got %d", rec.Code)
}
if reached {
t.Error("handler must not run on a rejected request — promotion would trust spoofed headers")
}
}

func TestRequireProxy_NoSecretInert_DoesNotPromote(t *testing.T) {
// Standalone `go test` (no token configured): the guard is inert and passes
// through, but it must NOT promote spoofable headers — nothing validated
// that they came from dispatch. Documented behavior: surface is open, app id
// unset (a module's own unit test injects identity explicitly if it needs
// one).
t.Setenv("MS_PLATFORM_TOKEN_FILE", "")
t.Setenv("MS_PLATFORM_TOKEN", "")
t.Setenv("MS_INTERNAL_SECRET", "")
var seen *Identity
handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = Get(r.Context())
}))

req := httptest.NewRequest("GET", "/public/me", nil)
req.Header.Set(HeaderAppID, "spoofed-app")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200 (inert without configured token), got %d", rec.Code)
}
if seen != nil {
t.Errorf("inert guard must not promote unvalidated headers; got identity %+v", seen)
}
}

func TestRequireProxy_PresetIdentity_NotClobbered(t *testing.T) {
// Lambda's InjectResources sets identity from the typed payload BEFORE any
// header promotion could run. The guard must never overwrite a preset
// identity with header values. Simulate by presetting on the context and
// sending conflicting headers on the validated-token path.
t.Setenv("MS_PLATFORM_TOKEN", "real-token")
var seen *Identity
handler := requireProxy(false)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
seen = Get(r.Context())
}))

req := httptest.NewRequest("GET", "/public/me", nil)
req.Header.Set(HeaderPlatformToken, "real-token")
req.Header.Set(HeaderAppID, "header-app")
req = req.WithContext(Set(req.Context(), Identity{
UserID: "preset-user",
AppID: "preset-app",
AppRole: RoleAdmin,
}))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if seen == nil || seen.AppID != "preset-app" {
t.Errorf("preset identity must win over headers; got %+v", seen)
}
}
14 changes: 12 additions & 2 deletions docs/concepts/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ Every HTTP route lives under one of three **scopes**. The scope determines which
| **Public** | `ms.Public(fn)` | None | Anonymous (webhooks, OAuth callbacks, public APIs) |
| **Internal** | `ms.Internal(fn)` | HMAC (`auth.InternalAuth`) | Platform itself (lifecycle, events, crons) |

## Reading your app id

On **both** Platform and Public routes, read your module's app id from the trusted context identity — never from request data (query string, body, path), which the caller controls and can forge:

```go
appID := ms.AppID(r.Context())
```

The SDK promotes the platform's trusted, dispatch-injected app id into the context **before your handler runs**: on Platform via the session auth, on Public via the proxy guard's validated-token path (the guard proves the request came through dispatch, so the `X-MS-App-ID` header it forwards is trustworthy). `ms.AppID` returns it; it returns `""` only in a standalone unit test where no platform token is configured. `ms.AppID` is the inbound twin of `ms.WithAppID` (which *retargets* an outbound `ms.Call` at a different app).

## Platform

Authenticated users of the host dashboard. The SDK checks a session token set by the platform's auth flow. Routes receive an `auth.Identity` via context with `AppID`, `UserID`, and `AppRole`.
Authenticated users of the host dashboard. The SDK checks a session token set by the platform's auth flow. Routes receive an `auth.Identity` via context with `AppID`, `UserID`, and `AppRole` — read the app id with `ms.AppID(r.Context())` (see above).

```go
import p "github.com/mirrorstack-ai/app-module-sdk/roles"
Expand All @@ -39,7 +49,7 @@ ms.Public(func(r chi.Router) {
})
```

The SDK does not apply any auth here, but you are responsible for verifying payloads that claim identity (signed webhooks, OAuth state nonces, etc.).
The SDK does not run user auth here, but the proxy guard still fronts every Public route: a request that did not come through the platform proxy is rejected with `403 not_proxied`. Because the guard validated the proxy token, the app id it promotes (`ms.AppID(r.Context())`) is trusted — use it instead of reading an app id off the request. You are still responsible for verifying payloads that claim *user* identity (signed webhooks, OAuth state nonces, etc.).

## Internal

Expand Down
14 changes: 12 additions & 2 deletions docs/zh-TW/concepts/scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@
| **Public** | `ms.Public(fn)` | 無 | 匿名(webhooks、OAuth callbacks、公開 API) |
| **Internal** | `ms.Internal(fn)` | HMAC(`auth.InternalAuth`) | 平台本身(lifecycle、events、crons) |

## 讀取自己的 app id

在 Platform 與 Public route **兩者**,都從受信任的 context identity 讀取自己模組的 app id,**不要**從 request 資料(query string、body、path)讀 — 那些是呼叫者可控、可偽造的:

```go
appID := ms.AppID(r.Context())
```

SDK 會在你的 handler 執行**之前**,把平台經 dispatch 注入、受信任的 app id 提升進 context:Platform 透過 session auth,Public 透過 proxy guard 驗證過 token 的路徑(guard 證明 request 確實經過 dispatch,所以它轉發的 `X-MS-App-ID` 是可信的)。`ms.AppID` 會回傳它;只有在沒有設定 platform token 的獨立單元測試裡才回傳 `""`。`ms.AppID` 是 `ms.WithAppID` 的「入站對偶」(`ms.WithAppID` 用來把一個 *對外* 的 `ms.Call` *改指向* 另一個 app)。

## Platform

已登入的 dashboard 使用者。SDK 會檢查 platform auth flow 發出的 session token。Route 可以從 context 取得 `auth.Identity`,內含 `AppID`、`UserID`、`AppRole`。
已登入的 dashboard 使用者。SDK 會檢查 platform auth flow 發出的 session token。Route 可以從 context 取得 `auth.Identity`,內含 `AppID`、`UserID`、`AppRole` — 用 `ms.AppID(r.Context())` 讀取 app id(見上)

```go
ms.Platform(func(r chi.Router) {
Expand All @@ -37,7 +47,7 @@ ms.Public(func(r chi.Router) {
})
```

SDK 在這層不做任何認證,但如果 payload 裡宣稱了身分(signed webhook、OAuth state nonce 等),你必須自己驗證
SDK 在這層不做使用者認證,但 proxy guard 仍然守在每個 Public route 前面:沒有經過平台 proxy 的 request 會被以 `403 not_proxied` 拒絕。因為 guard 驗證過 proxy token,它提升出來的 app id(`ms.AppID(r.Context())`)是可信的 — 用它,不要從 request 讀 app id。但如果 payload 裡宣稱了 *使用者* 身分(signed webhook、OAuth state nonce 等),你還是必須自己驗證

## Internal

Expand Down
16 changes: 16 additions & 0 deletions internal/core/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,19 @@ func CallGet(ctx context.Context, targetModuleID, path string, out any) error {
func CallPost(ctx context.Context, targetModuleID, path string, body, out any) error {
return mustDefault("CallPost").CallPost(ctx, targetModuleID, path, body, out)
}

// AppID returns the app id from the request context's auth identity, or "" if
// no identity is set. It is the inbound twin of WithAppID and the single
// unspoofable way a handler reads its own app: the SDK promotes the trusted,
// dispatch-injected app id into the identity (Platform via PlatformAuth, Public
// via the proxy guard's success path; Lambda via runtime.InjectResources).
// Reading request data (query/body) for the app id instead is forgeable.
//
// Not module-bound (no *Module receiver) — identity lives on the context, so
// this works before Init and in tests.
func AppID(ctx context.Context) string {
if a := auth.Get(ctx); a != nil {
return a.AppID
}
return ""
}
31 changes: 31 additions & 0 deletions internal/core/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,37 @@ func TestPublic_ProxyGuard_AllowsProxiedCaller(t *testing.T) {
}
}

func TestPublic_ProxyGuard_PromotesTrustedAppID(t *testing.T) {
// The #236 gap closure end-to-end: a Public route mounts ONLY the proxy
// guard (no PlatformAuth), so before this change AppID(ctx) was always empty
// on Public. Now the guard's validated-token path promotes the dispatch-
// injected X-MS-App-ID into the identity, so the handler reads its trusted
// app via core.AppID — never from request data.
m := newTestModuleWithSecret(t, "test") // MS_INTERNAL_SECRET = "secret"
var gotAppID string
m.Public(func(r chi.Router) {
r.Get("/start", func(w http.ResponseWriter, r *http.Request) {
gotAppID = AppID(r.Context())
_, _ = w.Write([]byte("started"))
})
})

req := httptest.NewRequest("GET", "/public/start", nil)
req.Header.Set("X-MS-Internal-Secret", "secret")
req.Header.Set(auth.HeaderAppID, "trusted-app-42")
req.Header.Set(auth.HeaderUserID, "u-9")
req.Header.Set(auth.HeaderAppRole, auth.RoleMember)
rec := httptest.NewRecorder()
m.Router().ServeHTTP(rec, req)

if rec.Code != 200 {
t.Fatalf("expected 200 for proxied caller, got %d", rec.Code)
}
if gotAppID != "trusted-app-42" {
t.Errorf("AppID(ctx) = %q on a Public route, want trusted-app-42 (promoted by the proxy guard)", gotAppID)
}
}

func TestPublic_ProxyGuard_InertWithoutToken(t *testing.T) {
// Standalone module `go test` (no platform token configured): the guard is
// inert so the public surface stays open for local unit tests.
Expand Down
31 changes: 26 additions & 5 deletions mirrorstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,36 @@ func Emit(ctx context.Context, name string, payload any) error {
return core.Emit(ctx, name, payload)
}

// AppID returns the current app id from the request context, or "" if no
// identity is set. It is the inbound twin of WithAppID and the single
// unspoofable way a handler reads its OWN app.
//
// On every guarded surface the SDK promotes the platform's trusted, dispatch-
// injected app id into the context identity BEFORE the handler runs — Platform
// via PlatformAuth, Public via the proxy guard's validated-token path, and
// Lambda via the typed invoke payload (runtime.InjectResources). So a Public or
// Platform handler reads its app with:
//
// appID := ms.AppID(r.Context())
//
// Do NOT read the app id from request data (query string, body, path) — those
// are caller-controlled and forgeable. ms.AppID is the trusted source.
func AppID(ctx context.Context) string {
return core.AppID(ctx)
}

// WithAppID returns a context whose inter-module Call scope is the given app,
// overriding the ambient identity's app. ms.Call reads the app id from the
// context (auth.Get) — for a handler that is the request's authenticated app,
// so authenticated callers need nothing extra. PUBLIC/proxy flows have no
// ambient identity (e.g. a sign-in proxy on ms.Public routes, where the target
// app arrives as request data, not as the caller's identity), so they set it
// explicitly:
// so authenticated callers need nothing extra.
//
// Use it to RETARGET an outbound ms.Call at a DIFFERENT app than the ambient
// one (the rare cross-app proxy case). To read your OWN app id, use ms.AppID —
// the SDK already promotes the request's trusted app into the identity, so a
// Public/Platform handler does not need WithAppID just to call within its own
// app:
//
// ctx = ms.WithAppID(ctx, appID)
// ctx = ms.WithAppID(ctx, otherAppID)
// ms.CallGet(ctx, providerModuleID, "/internal/authorize-url?"+q, &out)
//
// Any existing UserID/AppRole on the context is preserved; only AppID changes.
Expand Down
Loading
Loading