Skip to content

Commit ded7d02

Browse files
authored
chore: plugin auth (#28)
1 parent cb889f8 commit ded7d02

7 files changed

Lines changed: 134 additions & 68 deletions

File tree

plugins/backend.md

Lines changed: 102 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -47,49 +47,85 @@ headers below.
4747

4848
## Identity in your backend
4949

50-
Do not implement Steam OpenID, and do not try to parse the 5Stack session cookie.
51-
Put your service behind 5Stack's forward-auth endpoint instead: the 5Stack API
52-
validates the session and passes the identity to you as headers.
50+
Do not implement Steam OpenID, and do not try to decode the 5Stack session
51+
cookie — it is signed and `httpOnly`, and its contents are not a stable contract.
5352

54-
On Kubernetes with the nginx ingress:
55-
56-
```yaml
57-
nginx.ingress.kubernetes.io/auth-url: "http://api.5stack.svc.cluster.local:5585/custom-pages/authorize"
58-
nginx.ingress.kubernetes.io/auth-response-headers: "X-5stack-Steam-Id,X-5stack-Role,X-5stack-Name"
59-
```
60-
61-
Your backend then trusts three headers:
62-
63-
| Header | Contents |
64-
| ------------------- | ---------------------------------- |
65-
| `X-5stack-Steam-Id` | The authenticated user's SteamID64 |
66-
| `X-5stack-Role` | Their 5Stack role |
67-
| `X-5stack-Name` | Their display name |
53+
Instead, hand the cookie back to us. Every request the browser makes to your
54+
backend already carries the 5Stack session cookie (see
55+
[Hosting](#hosting-is-part-of-the-security-model)). Forward it to the panel's
56+
authorize endpoint and you get the identity as JSON:
6857

6958
```ts
7059
// identity.ts
71-
export function identify(req: FastifyRequest) {
72-
const steamId = req.headers["x-5stack-steam-id"] as string | undefined;
73-
if (!steamId) {
74-
// Local dev only. The NODE_ENV guard makes a leaked DEV_STEAM_ID harmless
75-
// in production, where the ingress rejects unauthenticated requests anyway.
76-
if (process.env.NODE_ENV !== "production" && process.env.DEV_STEAM_ID) {
77-
return { steamId: process.env.DEV_STEAM_ID, role: "administrator" };
78-
}
60+
const AUTH_URL =
61+
process.env.FIVESTACK_AUTH_URL ??
62+
"http://api.5stack.svc.cluster.local:5585/plugins/authorize";
63+
64+
export async function identify(req: FastifyRequest) {
65+
const cookie = req.headers.cookie;
66+
if (!cookie) {
67+
return null;
68+
}
69+
70+
const res = await fetch(AUTH_URL, { headers: { cookie } });
71+
if (!res.ok) {
7972
return null;
8073
}
81-
return {
82-
steamId,
83-
role: req.headers["x-5stack-role"] as string,
84-
name: req.headers["x-5stack-name"] as string,
74+
75+
return (await res.json()) as {
76+
steam_id: string;
77+
role: string;
78+
name: string;
8579
};
8680
}
8781
```
8882

89-
::: danger These headers are only trustworthy behind the gate
90-
They are plain HTTP headers. If your backend is reachable without passing through
91-
the forward-auth ingress, anyone can set them. Never expose the service directly,
92-
and keep any `DEV_STEAM_ID`-style fallback strictly out of production.
83+
A `200` means the session is valid. `401` means there is no session. Treat
84+
anything non-`200` as anonymous.
85+
86+
::: tip Cache the lookup
87+
This is one extra in-cluster round-trip per request. Cache the result for a few
88+
seconds keyed on the cookie value — long enough to collapse a page's burst of
89+
API calls, short enough that a logout takes effect promptly.
90+
:::
91+
92+
This fails closed. If your backend is misconfigured or unexpectedly reachable,
93+
the worst case is that requests are rejected — never that an attacker is
94+
believed.
95+
96+
### Optional: forward-auth at the ingress
97+
98+
If you would rather spend the round-trip once at the edge than once per request
99+
in your process, nginx can call the same endpoint for you and inject the result
100+
as headers:
101+
102+
```yaml
103+
nginx.ingress.kubernetes.io/auth-url: "http://api.5stack.svc.cluster.local:5585/plugins/authorize"
104+
nginx.ingress.kubernetes.io/auth-response-headers: "X-5stack-Steam-Id,X-5stack-Role,X-5stack-Name"
105+
```
106+
107+
| Header | Contents |
108+
| ------------------- | ---------------------------------------------- |
109+
| `X-5stack-Steam-Id` | The authenticated user's SteamID64 |
110+
| `X-5stack-Role` | Their 5Stack role |
111+
| `X-5stack-Name` | Their display name, URI-encoded |
112+
113+
::: danger This mode fails open — know what you are signing up for
114+
These are plain HTTP headers. nginx overwrites them on the way through, so they
115+
are trustworthy *only* on traffic that actually traversed that ingress. Expose
116+
the Service any other way — a second ingress without the annotations, a
117+
NodePort, a LoadBalancer, a port-forward — and `curl -H "X-5stack-Role:
118+
administrator"` is an admin session. Nothing will warn you.
119+
120+
Prefer the cookie check above unless you have measured a reason not to. If you
121+
do use this mode, keep the backend a `ClusterIP` Service with the annotated
122+
ingress as its only route in.
123+
:::
124+
125+
::: warning Never ship a `DEV_STEAM_ID` escape hatch unguarded
126+
A local-dev fallback that returns an `administrator` when no identity is present
127+
is unauthenticated admin the moment that variable exists in a real environment.
128+
Guard it on `process.env.NODE_ENV !== "production"`, not on a comment.
93129
:::
94130

95131
## Calling your API from the plugin
@@ -105,7 +141,8 @@ const API_BASE =
105141
106142
export async function get<T>(path: string): Promise<T> {
107143
const res = await fetch(`${API_BASE}/api${path}`, {
108-
// Required — carries the 5Stack session cookie to the forward-auth gate.
144+
// Required — carries the 5Stack session cookie, which is the only thing
145+
// that lets your backend establish who is calling.
109146
credentials: "include",
110147
});
111148
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
@@ -116,7 +153,7 @@ export async function get<T>(path: string): Promise<T> {
116153
Two details matter:
117154

118155
- **`credentials: "include"` on every request.** Without it the session cookie
119-
never reaches the gate and the ingress rejects you. This also applies to plain
156+
never arrives and every request is anonymous. This also applies to plain
120157
`<img>` tags pointing at gated endpoints, which send cookies automatically only
121158
when same-site.
122159
- **CORS with credentials.** Your backend must reflect the requesting origin and
@@ -126,10 +163,33 @@ Two details matter:
126163
await app.register(cors, { origin: true, credentials: true });
127164
```
128165

129-
::: tip Hosting on a subdomain of the panel
130-
Serving your plugin from `myplugin.panel.example.com` keeps the 5Stack session
131-
cookie same-site, which sidesteps a whole category of third-party-cookie
132-
problems. It is the simplest arrangement that works.
166+
## Hosting is part of the security model
167+
168+
**Your backend must be served from a subdomain of the panel's domain**
169+
`myplugin.panel.example.com` for a panel at `panel.example.com`. This is a
170+
requirement, not a preference.
171+
172+
The 5Stack session cookie is issued for `.panel.example.com` with the browser
173+
default `SameSite=Lax`. Lax cookies are not attached to cross-site subresource
174+
requests, so a backend on an unrelated domain receives no cookie at all — no
175+
matter what `credentials: "include"` says, and no matter how the ingress is
176+
annotated. Identity is simply unavailable there.
177+
178+
Two useful consequences fall out of this:
179+
180+
- Hosting in-cluster, behind the panel's own domain, is the only arrangement that
181+
works — which is also the arrangement where your Service is not casually
182+
reachable from outside.
183+
- Because the cookie is `httpOnly`, your plugin's frontend can never read it. It
184+
rides along on requests and is exchanged for identity only by your backend
185+
talking to the panel.
186+
187+
::: warning Your backend receives a live session cookie
188+
Whichever identity mode you pick, the cookie reaches your service. It is a bearer
189+
credential for the calling user's 5Stack account. Do not log it, do not persist
190+
it, and do not forward it anywhere except the authorize endpoint. This is why
191+
plugins are admin-installed and why the panel treats a plugin backend as
192+
fully trusted code.
133193
:::
134194

135195
## Storing data
@@ -161,8 +221,8 @@ overwrite you.
161221
## Machine-to-machine access
162222

163223
If a game server or another service needs to reach your API without a browser
164-
session, forward-auth will not help — there is no user. Issue your own API key:
165-
generate it from an admin screen in your plugin, store it in your schema, and
166-
check it on a route excluded from the forward-auth ingress path.
224+
session, neither identity mode helps — there is no user and no cookie. Issue your
225+
own API key: generate it from an admin screen in your plugin, store it in your
226+
schema, and check it on a route excluded from the session check.
167227

168228
Keep those routes narrow and separate from the session-authenticated ones.

plugins/components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ match the panel's versions:
176176

177177
::: info
178178
`class-variance-authority` is a peer dependency of `@5stack/ui` but is not in the
179-
hello-world sample, because nothing currently exported from the package uses it.
179+
example plugin, because nothing currently exported from the package uses it.
180180
The moment you copy in a `cva`-based component — or the official components land
181181
— you need it. Adding it up front costs nothing.
182182
:::

plugins/deploying.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,10 @@ entry, which helps — but do not rely on it in place of correct headers.
7272
Any static host works: nginx in a container, an object-storage bucket behind a
7373
CDN, or a static-site host.
7474

75-
Serving from a **subdomain of the panel** (`myplugin.panel.example.com`) is the
76-
simplest arrangement, because it keeps the 5Stack session cookie same-site for
77-
any backend calls. See [Backend & Auth](/plugins/backend).
75+
If you have a backend, serving from a **subdomain of the panel**
76+
(`myplugin.panel.example.com`) is required, not just convenient — the 5Stack
77+
session cookie is `SameSite=Lax` and never reaches an unrelated domain, so
78+
identity is unavailable there. See [Backend & Auth](/plugins/backend).
7879

7980
A minimal container:
8081

@@ -101,15 +102,16 @@ the panel repo under `custom/<name>/` can be applied with:
101102
```
102103

103104
The inventory plugin is laid out this way — two ingresses on one host, `/api` to
104-
the backend with the forward-auth annotations and `/` to the static frontend.
105-
Its `k8s/` directory is a working template. See also
105+
the backend and `/` to the static frontend. Keep the backend a `ClusterIP`
106+
Service so the ingress is its only route in. Its `k8s/` directory is a working
107+
template. See also
106108
[Custom Kubernetes](/advanced/custom-k8s).
107109

108110
## Register it
109111

110-
In the panel, go to **Settings → Application → Custom Pages**.
112+
In the panel, go to **Settings → Application → Plugins**.
111113

112-
1. Make sure the **Custom Pages** master switch is enabled.
114+
1. Make sure the **Plugins** master switch is enabled.
113115
2. **Add**, paste your base URL (e.g. `https://myplugin.example.com`).
114116
3. Press **Detect**. The panel fetches `5stack-plugin.json` and fills in the
115117
name, slug, icon, remote entry, scope, module, and required role.

plugins/getting-started.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# Getting Started
22

33
The fastest path to a working plugin is to copy the
4-
[hello-world sample](https://github.com/5stackgg/5stack-plugin-hello-world). It
4+
[example plugin](https://github.com/5stackgg/5stack-example-plugin). It
55
is deliberately tiny — one component, one manifest, one Vite config — and it is
66
already wired for everything described in this section.
77

88
```sh
9-
git clone https://github.com/5stackgg/5stack-plugin-hello-world my-plugin
9+
git clone https://github.com/5stackgg/5stack-example-plugin my-plugin
1010
cd my-plugin
1111
rm -rf .git && git init
1212
npm install
@@ -83,7 +83,7 @@ npm run build
8383
npx vite preview # serves dist/ with cors enabled
8484
```
8585

86-
Then register `http://localhost:4173` as a Custom Page in your dev panel. See
86+
Then register `http://localhost:4173` as a plugin in your dev panel. See
8787
[Deploying](/plugins/deploying) for the CORS and cache headers this needs.
8888

8989
## Write your page
@@ -120,9 +120,9 @@ role check is never sufficient on its own.
120120
npm run build # -> dist/remoteEntry.js in assets/, plus dist/5stack-plugin.json
121121
```
122122

123-
Host `dist/`, then in the panel go to **Settings → Application → Custom Pages
123+
Host `dist/`, then in the panel go to **Settings → Application → Plugins
124124
Add**, paste your base URL, press **Detect**, toggle **Enabled**, and save. Make
125-
sure the **Custom Pages** master switch is on.
125+
sure the **Plugins** master switch is on.
126126

127127
Your page is now in the sidebar at `/apps/<slug>`.
128128

plugins/index.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Plugin Development
22

3-
Plugins — called **Custom Pages** in the panel UI — let you run your own web app
3+
Plugins let you run your own web app
44
**inside 5Stack**. Same sidebar, same header, same theme, same login. Your app is
55
not part of 5Stack and does not require a fork or a rebuild of the panel.
66

@@ -11,8 +11,8 @@ remote. The panel loads it at runtime and mounts it on a native route,
1111
- **No iframe.** Your component runs inside the panel's own Vue app, sharing its
1212
Vue instance, its router, and its styling.
1313
- **No second login.** The panel hands you the authenticated user. If you have a
14-
backend, it sits behind 5Stack forward-auth instead of running its own Steam
15-
OpenID flow.
14+
backend, it exchanges the session cookie the browser already sends for a
15+
verified identity, instead of running its own Steam OpenID flow.
1616
- **Native look.** The shared `@5stack/ui` Tailwind preset and design tokens mean
1717
your UI follows the operator's live branding automatically.
1818
- **No panel rebuild.** Plugins live in a database registry. An admin adds a URL;
@@ -31,9 +31,9 @@ SwiftlyS2 or CounterStrikeSharp plugins on a game server, see
3131
Federation remote. The build emits `dist/assets/remoteEntry.js`.
3232
2. You ship a manifest, `5stack-plugin.json`, at the root of that same build.
3333
3. You host `dist/` somewhere the panel's users can reach.
34-
4. An admin pastes your URL into **Settings → Application → Custom Pages**, hits
34+
4. An admin pastes your URL into **Settings → Application → Plugins**, hits
3535
**Detect**, and enables it.
36-
5. The panel writes a row into its `custom_pages` registry. Every connected
36+
5. The panel writes a row into its plugin registry. Every connected
3737
client picks the new entry up over a live subscription, renders a sidebar
3838
item, and — when a user navigates to `/apps/<slug>` — fetches your
3939
`remoteEntry.js`, resolves your exposed module, and mounts it:
@@ -61,7 +61,11 @@ you call [your own backend](/plugins/backend).
6161

6262
::: info No sandbox
6363
A plugin is loaded into the panel's JavaScript context with no isolation. It can
64-
read the host's cookies and reach into its stores. `requiredRole` controls who
64+
reach into the host's stores and act as the logged-in user against any API the
65+
panel can reach, and its backend receives that user's live session cookie. (The
66+
cookie itself is `httpOnly`, so plugin JavaScript cannot read it directly — but
67+
that is a small consolation given everything else it can do.) `requiredRole`
68+
controls who
6569
*sees* the page, not what the code *can do*. There is also no integrity pinning
6670
on `remoteEntry.js` — the panel loads whatever the registered URL serves, so a
6771
compromised plugin host compromises the panel for every user until the page is
@@ -73,8 +77,8 @@ yours.
7377

7478
| Repo | What it shows |
7579
| --- | --- |
76-
| [5stack-plugin-hello-world](https://github.com/5stackgg/5stack-plugin-hello-world) | The smallest complete plugin. Start here — copy it. |
77-
| [5stack-inventory-plugin](https://github.com/lukepolo/5stack-inventory-plugin) | A production plugin with a Fastify backend, Postgres, forward-auth, and a Kubernetes deployment. |
80+
| [5stack-example-plugin](https://github.com/5stackgg/5stack-example-plugin) | The smallest complete plugin. Start here — copy it. |
81+
| [5stack-inventory-plugin](https://github.com/lukepolo/5stack-inventory-plugin) | A production plugin with a Fastify backend, Postgres, session-cookie auth, and a Kubernetes deployment. |
7882

7983
## Next steps
8084

@@ -86,5 +90,5 @@ yours.
8690
- [Styling](/plugins/styling) — Tailwind setup, design tokens, and the CSS
8791
pitfalls unique to runtime-injected styles.
8892
- [Components](/plugins/components) — what `@5stack/ui` actually gives you today.
89-
- [Backend & Auth](/plugins/backend)forward-auth and talking to your own API.
93+
- [Backend & Auth](/plugins/backend)verifying identity and talking to your own API.
9094
- [Deploying](/plugins/deploying) — hosting, CORS, caching, and registration.

plugins/manifest.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
project's `public/` directory so Vite copies it to `dist/5stack-plugin.json`,
55
where it is served from the root of your build.
66

7-
An admin pastes your base URL into **Settings → Application → Custom Pages
7+
An admin pastes your base URL into **Settings → Application → Plugins
88
Detect**, the panel fetches the manifest, and every field below is auto-filled
99
into the registration form.
1010

@@ -102,8 +102,8 @@ is in [Backend & Auth](/plugins/backend#roles).
102102

103103
::: warning `requiredRole` is visibility, not security
104104
It controls whether the sidebar entry and the route render. It does not protect
105-
your data. Anything sensitive must be enforced by your own backend against the
106-
forward-auth headers — see [Backend & Auth](/plugins/backend).
105+
your data. Anything sensitive must be re-checked by your own backend against a
106+
verified identity — see [Backend & Auth](/plugins/backend).
107107
:::
108108

109109
## Serving it

plugins/module-federation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Two rules follow.
7878

7979
**Only list what you actually import.** Federation builds a shared entry chunk for
8080
every key in `shared`, and a package that is listed but not installed fails the
81-
build. The hello-world sample lists six because it imports six. Add `pinia` or
81+
build. The example plugin lists six because it imports six. Add `pinia` or
8282
`vue-router` to both `shared` and your `package.json` only once you actually use
8383
them.
8484

0 commit comments

Comments
 (0)