@user-hub/auth 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +84 -0
- package/dist/convex-7n6dqf5r.js +131 -0
- package/dist/convex-react.d.ts +13 -0
- package/dist/convex-react.js +41 -0
- package/dist/convex.d.ts +31 -0
- package/dist/convex.js +48 -0
- package/dist/react.d.ts +33 -0
- package/dist/react.js +14 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +54 -0
- package/dist/token.d.ts +17 -0
- package/package.json +71 -4
- package/skills/user-hub-auth/SKILL.md +135 -0
package/README.md
CHANGED
|
@@ -1,3 +1,171 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @user-hub/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> **Private beta.** This package needs a User Hub, which is not public yet. Until then it is only useful to teams already running one.
|
|
5
|
+
|
|
6
|
+
Sign people into your app with their User Hub badge. People scan a printed QR code and arrive in your app already signed in. You get `{ id, name, team, role }` and build no sign-in screens, passwords or sessions.
|
|
7
|
+
|
|
8
|
+
Works with Convex apps, React frontends and any server that can check a token.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
1. A person scans their badge. The hub sends them to your app with a token in the URL: `https://your-app.com/#token=…`.
|
|
13
|
+
2. The provider takes the token out of the address bar and keeps it in the browser, separately for each app.
|
|
14
|
+
3. Convex (or your server) checks the token with the hub's public key. A token is only accepted by the app it was made for, and lasts 4 hours.
|
|
15
|
+
4. Your app stores its own data under the person's `id`.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
### 1. Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun add @user-hub/auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Using a coding agent? The package ships an agent skill at `node_modules/@user-hub/auth/skills/user-hub-auth`; point your agent to it.
|
|
26
|
+
|
|
27
|
+
### 2. Register your app with the hub
|
|
28
|
+
|
|
29
|
+
Ask the hub's admin for its URL and the admin password, and put both in your app's `.env` or `.env.local` (keep it gitignored), or your secret manager:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
HUB_URL=https://hub.example.com
|
|
33
|
+
HUB_ADMIN_PASSWORD=…
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then, from your app's folder (`bunx` reads `.env` and `.env.local` automatically; with a secret manager, run it through that):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bunx hub-user register --name "Your App" --prod https://your-app.com --dev http://localhost:3000
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It prints your **app key** (made from the name) and the values to set. Not deployed yet? Leave out `--prod`: you can develop, and badge scans start reaching the app once you add it. Run it again with the same `--name` to update the URLs; a URL left out keeps the registered one. Give every app its own name: the same name updates the same app. (A hub admin can do the same under **Apps** in the hub, and copy the values from **Setup**.)
|
|
43
|
+
|
|
44
|
+
### 3. Set the values
|
|
45
|
+
|
|
46
|
+
On your Convex deployment (every deployment you use):
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
bunx convex env set HUB_ISSUER '…'
|
|
50
|
+
bunx convex env set HUB_JWKS '…'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In your frontend env: `VITE_HUB_URL`, the same value as `HUB_URL`.
|
|
54
|
+
|
|
55
|
+
### 4. Wire it up
|
|
56
|
+
|
|
57
|
+
`convex/auth.config.ts`:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import type { AuthConfig } from "convex/server";
|
|
61
|
+
import { hubAuth } from "@user-hub/auth/convex";
|
|
62
|
+
|
|
63
|
+
export default { providers: [hubAuth({ app: "your-app-key" })] } satisfies AuthConfig;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
In place of `ConvexProvider`, **inside the root document's `<body>`** (in TanStack Start, the root route's shell component; never the router's `Wrap` or anything outside `<html>`):
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { ConvexHubAuthProvider, HubSignedIn } from "@user-hub/auth/convex-react";
|
|
70
|
+
|
|
71
|
+
<body>
|
|
72
|
+
<ConvexHubAuthProvider client={convex} app="your-app-key" hubUrl={import.meta.env.VITE_HUB_URL}>
|
|
73
|
+
<HubSignedIn>{children}</HubSignedIn>
|
|
74
|
+
</ConvexHubAuthProvider>
|
|
75
|
+
<Scripts />
|
|
76
|
+
</body>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The provider always renders its children. `<HubSignedIn>` shows them only to a signed-in person, and "Scan your badge to sign in" otherwise; anything outside it shows to everyone.
|
|
80
|
+
|
|
81
|
+
### 5. Use the person
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { useHubUser } from "@user-hub/auth/react"; // also exported by /convex-react
|
|
85
|
+
|
|
86
|
+
const me = useHubUser(); // { id, name, team, role }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { requireHubUser } from "@user-hub/auth/convex";
|
|
91
|
+
|
|
92
|
+
export const myVotes = query({
|
|
93
|
+
args: {},
|
|
94
|
+
handler: async (ctx) => {
|
|
95
|
+
const me = await requireHubUser(ctx); // throws when signed out
|
|
96
|
+
return await ctx.db.query("votes").withIndex("by_person", (q) => q.eq("personId", me.id)).take(50);
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Working on your app locally
|
|
102
|
+
|
|
103
|
+
Run your dev server as usual and open it. With no token yet, it sends you to the hub, where you pick who to be (the admin password is asked once per browser), and brings you back signed in. You stay signed in for 4 hours, across reloads and restarts.
|
|
104
|
+
|
|
105
|
+
- **Switch person:** open `<hub>/open/<your-app-key>`.
|
|
106
|
+
- **Different port:** run `hub-user register` again with the new `--dev` URL. The hub only sends tokens to registered URLs.
|
|
107
|
+
- **Production:** someone without a token sees "Scan your badge to sign in". Pass `signedOut={…}` to `<HubSignedIn>` to style that screen.
|
|
108
|
+
|
|
109
|
+
## Facilitator pages
|
|
110
|
+
|
|
111
|
+
People are members or facilitators. Always check on the server, then hide what members cannot use:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { requireFacilitator } from "@user-hub/auth/convex";
|
|
115
|
+
|
|
116
|
+
export const closeVoting = mutation({
|
|
117
|
+
args: {},
|
|
118
|
+
handler: async (ctx) => {
|
|
119
|
+
await requireFacilitator(ctx); // throws for members
|
|
120
|
+
// …
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
const me = useHubUser();
|
|
127
|
+
if (me?.role !== "facilitator") return <p>This page is for facilitators.</p>;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Hiding a page does not protect it: the server check does.
|
|
131
|
+
|
|
132
|
+
## Not using Convex?
|
|
133
|
+
|
|
134
|
+
Send the token to your server and check it there:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
const token = useHubToken();
|
|
138
|
+
fetch("/api/thing", { headers: { Authorization: `Bearer ${token}` } });
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { verifyHubToken } from "@user-hub/auth/server";
|
|
143
|
+
|
|
144
|
+
const me = await verifyHubToken(request, { app: "your-app-key" }); // reads HUB_ISSUER and HUB_JWKS
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Apps without Convex use `HubAuthProvider` and `HubSignedIn` from `@user-hub/auth/react`, in the same place, and do not need Convex installed.
|
|
148
|
+
|
|
149
|
+
## API
|
|
150
|
+
|
|
151
|
+
| Import | Export | What |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `@user-hub/auth/convex` | `hubAuth({ app })` | The Convex auth provider, from `HUB_ISSUER` and `HUB_JWKS` |
|
|
154
|
+
| | `getHubUser(ctx)`, `requireHubUser(ctx)`, `requireFacilitator(ctx)` | The person in Convex functions: or null, or throw |
|
|
155
|
+
| `@user-hub/auth/convex-react` | `ConvexHubAuthProvider` | For Convex apps, in place of `ConvexProvider`, inside `<body>`. Props: `client`, `app`, `hubUrl?` |
|
|
156
|
+
| `@user-hub/auth/react` | `HubAuthProvider` | The same without Convex: `app`, `hubUrl?` |
|
|
157
|
+
| both | `HubSignedIn` | Shows its children only when signed in. Props: `signedOut?`, `loading?` |
|
|
158
|
+
| | `useHubUser()`, `useHubToken()` | The person (or null), the raw token (or null). Also from `/convex-react` |
|
|
159
|
+
| `@user-hub/auth/server` | `verifyHubToken(requestOrToken, { app })` | Checks a token anywhere else |
|
|
160
|
+
| CLI | `hub-user register --name <name> [--prod <url>] [--dev <url>] [--hub <url>] [--json]` | Registers or updates the app (at least one URL) |
|
|
161
|
+
|
|
162
|
+
## Developing this package
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
bun install
|
|
166
|
+
bun test # unit tests
|
|
167
|
+
bun run check # Biome
|
|
168
|
+
bun run build # dist/: commit it
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Publish with `bun publish`: it builds and runs the tests first.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
var USAGE = `Usage: hub-user register --name <name> [--prod <url>] [--dev <url>] [--hub <url>] [--json]
|
|
6
|
+
|
|
7
|
+
--name The app's name in the hub; its key comes from it (required)
|
|
8
|
+
--prod Where badge scans send people. Leave it out until the app is
|
|
9
|
+
deployed: it can be developed, but not scheduled.
|
|
10
|
+
--dev Where developers open the app as a person, e.g. http://localhost:3000
|
|
11
|
+
--hub The hub's URL (default: HUB_URL)
|
|
12
|
+
--json Print JSON instead of text
|
|
13
|
+
|
|
14
|
+
At least one of --prod and --dev is needed. A URL left out on a later run keeps
|
|
15
|
+
the registered one.
|
|
16
|
+
|
|
17
|
+
Needs HUB_URL and HUB_ADMIN_PASSWORD in the environment (.env and .env.local
|
|
18
|
+
are read automatically). Running it again with the same
|
|
19
|
+
name updates that app.`;
|
|
20
|
+
function fail(message) {
|
|
21
|
+
console.error(`hub-user: ${message}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
var { values, positionals } = parseArgs({
|
|
25
|
+
args: process.argv.slice(2),
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
prod: { type: "string" },
|
|
29
|
+
dev: { type: "string" },
|
|
30
|
+
name: { type: "string" },
|
|
31
|
+
hub: { type: "string" },
|
|
32
|
+
json: { type: "boolean", default: false },
|
|
33
|
+
help: { type: "boolean", short: "h", default: false }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
if (values.help || positionals[0] !== "register") {
|
|
37
|
+
console.log(USAGE);
|
|
38
|
+
process.exit(values.help ? 0 : 1);
|
|
39
|
+
}
|
|
40
|
+
var hub = (values.hub ?? process.env.HUB_URL)?.replace(/\/+$/, "");
|
|
41
|
+
var password = process.env.HUB_ADMIN_PASSWORD;
|
|
42
|
+
var name = values.name?.trim();
|
|
43
|
+
if (!hub)
|
|
44
|
+
fail("set HUB_URL (in .env or .env.local) or pass --hub");
|
|
45
|
+
if (!password)
|
|
46
|
+
fail("set HUB_ADMIN_PASSWORD (in .env or .env.local)");
|
|
47
|
+
if (!values.prod && !values.dev)
|
|
48
|
+
fail("pass --prod <url>, --dev <url> or both");
|
|
49
|
+
if (!name)
|
|
50
|
+
fail("pass --name <name>, the app's name in the hub");
|
|
51
|
+
var response = await fetch(`${hub}/api/apps/register`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${password}`,
|
|
55
|
+
"Content-Type": "application/json"
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
name,
|
|
59
|
+
...values.prod !== undefined && { url: values.prod },
|
|
60
|
+
...values.dev !== undefined && { devUrl: values.dev }
|
|
61
|
+
})
|
|
62
|
+
}).catch((error) => fail(`could not reach ${hub}: ${error.message}`));
|
|
63
|
+
var result = await response.json().catch(() => ({}));
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
fail(result.error ?? `the hub answered ${response.status}`);
|
|
66
|
+
}
|
|
67
|
+
if (values.json) {
|
|
68
|
+
console.log(JSON.stringify(result, null, 2));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(`Registered "${name}" with key: ${result.key}
|
|
71
|
+
|
|
72
|
+
Environment (Convex deployment, or the app's server):
|
|
73
|
+
HUB_ISSUER=${result.env.HUB_ISSUER}
|
|
74
|
+
HUB_JWKS=${result.env.HUB_JWKS}
|
|
75
|
+
|
|
76
|
+
For Convex:
|
|
77
|
+
bunx convex env set HUB_ISSUER '${result.env.HUB_ISSUER}'
|
|
78
|
+
bunx convex env set HUB_JWKS '${result.env.HUB_JWKS}'
|
|
79
|
+
|
|
80
|
+
Frontend (the dev server sends you to the hub to pick who to be):
|
|
81
|
+
VITE_HUB_URL=${result.hubUrl}
|
|
82
|
+
|
|
83
|
+
Then use the key in code: hubAuth({ app: "${result.key}" }) and <ConvexHubAuthProvider app="${result.key}" hubUrl={import.meta.env.VITE_HUB_URL} client={convex}>`);
|
|
84
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// src/react.tsx
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
// src/token.ts
|
|
11
|
+
function decodeSegment(segment) {
|
|
12
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
|
+
return JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")));
|
|
14
|
+
}
|
|
15
|
+
function readClaims(token) {
|
|
16
|
+
try {
|
|
17
|
+
const payload = decodeSegment(token.split(".")[1] ?? "");
|
|
18
|
+
if (typeof payload.sub !== "string" || typeof payload.exp !== "number" || typeof payload.aud !== "string") {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
id: payload.sub,
|
|
23
|
+
name: payload.name,
|
|
24
|
+
team: payload.team,
|
|
25
|
+
role: payload.role === "facilitator" ? "facilitator" : "member",
|
|
26
|
+
app: payload.aud,
|
|
27
|
+
expiresAt: payload.exp * 1000
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function sessionFor(token, app, now = Date.now()) {
|
|
34
|
+
const user = token ? readClaims(token) : null;
|
|
35
|
+
return token && user && user.app === app && user.expiresAt > now ? { token, user } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/react.tsx
|
|
39
|
+
import { jsx } from "react/jsx-runtime";
|
|
40
|
+
var MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
41
|
+
var LOCAL_HOST = /^(localhost|127(\.\d{1,3}){3}|10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|\[::1\]|[a-z0-9-]+\.local)$/i;
|
|
42
|
+
var HubContext = createContext({
|
|
43
|
+
session: undefined,
|
|
44
|
+
redirecting: false
|
|
45
|
+
});
|
|
46
|
+
var storageKey = (app) => `user-hub-token:${app}`;
|
|
47
|
+
function readStorage(key) {
|
|
48
|
+
try {
|
|
49
|
+
return window.localStorage.getItem(key);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function writeStorage(key, token) {
|
|
55
|
+
try {
|
|
56
|
+
if (token)
|
|
57
|
+
window.localStorage.setItem(key, token);
|
|
58
|
+
else
|
|
59
|
+
window.localStorage.removeItem(key);
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
function loadSession(app) {
|
|
63
|
+
const hash = new URLSearchParams(window.location.hash.slice(1));
|
|
64
|
+
const fromUrl = hash.get("token");
|
|
65
|
+
if (fromUrl) {
|
|
66
|
+
hash.delete("token");
|
|
67
|
+
const rest = hash.toString();
|
|
68
|
+
window.history.replaceState(window.history.state, "", `${window.location.pathname}${window.location.search}${rest ? `#${rest}` : ""}`);
|
|
69
|
+
}
|
|
70
|
+
const session = sessionFor(fromUrl, app) ?? sessionFor(readStorage(storageKey(app)), app);
|
|
71
|
+
writeStorage(storageKey(app), session?.token ?? null);
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
function HubAuthProvider2({
|
|
75
|
+
app,
|
|
76
|
+
hubUrl,
|
|
77
|
+
children
|
|
78
|
+
}) {
|
|
79
|
+
const [session, setSession] = useState();
|
|
80
|
+
const redirecting = session === null && Boolean(hubUrl) && typeof window !== "undefined" && LOCAL_HOST.test(window.location.hostname);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setSession(loadSession(app));
|
|
83
|
+
}, [app]);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!session)
|
|
86
|
+
return;
|
|
87
|
+
const id = setTimeout(() => {
|
|
88
|
+
writeStorage(storageKey(app), null);
|
|
89
|
+
setSession(null);
|
|
90
|
+
}, Math.min(session.user.expiresAt - Date.now(), MAX_TIMEOUT_MS));
|
|
91
|
+
return () => clearTimeout(id);
|
|
92
|
+
}, [session, app]);
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (redirecting && hubUrl) {
|
|
95
|
+
window.location.replace(`${hubUrl.replace(/\/+$/, "")}/open/${encodeURIComponent(app)}`);
|
|
96
|
+
}
|
|
97
|
+
}, [redirecting, hubUrl, app]);
|
|
98
|
+
const state = useMemo(() => ({ session, redirecting }), [session, redirecting]);
|
|
99
|
+
return /* @__PURE__ */ jsx(HubContext.Provider, {
|
|
100
|
+
value: state,
|
|
101
|
+
children
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function DefaultSignedOut() {
|
|
105
|
+
return /* @__PURE__ */ jsx("p", {
|
|
106
|
+
style: { padding: "2rem", textAlign: "center" },
|
|
107
|
+
children: "Scan your badge to sign in."
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function HubSignedIn2({
|
|
111
|
+
children,
|
|
112
|
+
signedOut = /* @__PURE__ */ jsx(DefaultSignedOut, {}),
|
|
113
|
+
loading = null
|
|
114
|
+
}) {
|
|
115
|
+
const { session, redirecting } = useContext(HubContext);
|
|
116
|
+
if (session === undefined || redirecting)
|
|
117
|
+
return loading;
|
|
118
|
+
return session ? children : signedOut;
|
|
119
|
+
}
|
|
120
|
+
function useHubSession2() {
|
|
121
|
+
return useContext(HubContext).session;
|
|
122
|
+
}
|
|
123
|
+
function useHubUser2() {
|
|
124
|
+
const user = useHubSession2()?.user;
|
|
125
|
+
return useMemo(() => user ? { id: user.id, name: user.name, team: user.team, role: user.role } : null, [user]);
|
|
126
|
+
}
|
|
127
|
+
function useHubToken2() {
|
|
128
|
+
return useHubSession2()?.token ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { HubAuthProvider2, HubSignedIn2, useHubSession2, useHubUser2, useHubToken2 };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ConvexReactClient } from "convex/react";
|
|
2
|
+
import { type HubAuthProviderProps } from "./react";
|
|
3
|
+
export { HubSignedIn, useHubSession, useHubToken, useHubUser, } from "./react";
|
|
4
|
+
export type { HubRole, HubUser } from "./token";
|
|
5
|
+
/**
|
|
6
|
+
* HubAuthProvider for apps on Convex: the hub token becomes the Convex
|
|
7
|
+
* identity. Use it in place of ConvexProvider, with the same client, inside
|
|
8
|
+
* the root document's <body>. Always renders its children; wrap signed-in
|
|
9
|
+
* content in <HubSignedIn>.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ConvexHubAuthProvider({ client, children, ...props }: HubAuthProviderProps & {
|
|
12
|
+
client: ConvexReactClient;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HubAuthProvider2,
|
|
3
|
+
HubSignedIn2,
|
|
4
|
+
useHubSession2,
|
|
5
|
+
useHubUser2,
|
|
6
|
+
useHubToken2
|
|
7
|
+
} from "./convex-7n6dqf5r.js";
|
|
8
|
+
|
|
9
|
+
// src/convex-react.tsx
|
|
10
|
+
import { ConvexProviderWithAuth } from "convex/react";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { jsx } from "react/jsx-runtime";
|
|
13
|
+
function useConvexHubAuth() {
|
|
14
|
+
const session = useHubSession2();
|
|
15
|
+
return useMemo(() => ({
|
|
16
|
+
isLoading: session === undefined,
|
|
17
|
+
isAuthenticated: Boolean(session),
|
|
18
|
+
fetchAccessToken: async () => session && session.user.expiresAt > Date.now() ? session.token : null
|
|
19
|
+
}), [session]);
|
|
20
|
+
}
|
|
21
|
+
function ConvexHubAuthProvider({
|
|
22
|
+
client,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}) {
|
|
26
|
+
return /* @__PURE__ */ jsx(HubAuthProvider2, {
|
|
27
|
+
...props,
|
|
28
|
+
children: /* @__PURE__ */ jsx(ConvexProviderWithAuth, {
|
|
29
|
+
client,
|
|
30
|
+
useAuth: useConvexHubAuth,
|
|
31
|
+
children
|
|
32
|
+
})
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
ConvexHubAuthProvider,
|
|
37
|
+
HubSignedIn2 as HubSignedIn,
|
|
38
|
+
useHubSession2 as useHubSession,
|
|
39
|
+
useHubToken2 as useHubToken,
|
|
40
|
+
useHubUser2 as useHubUser
|
|
41
|
+
};
|
package/dist/convex.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Auth, AuthConfig } from "convex/server";
|
|
2
|
+
import type { HubUser } from "./token";
|
|
3
|
+
export type { HubRole, HubUser } from "./token";
|
|
4
|
+
type Provider = AuthConfig["providers"][number];
|
|
5
|
+
/**
|
|
6
|
+
* The Convex auth provider for hub tokens. In `convex/auth.config.ts`:
|
|
7
|
+
*
|
|
8
|
+
* export default { providers: [hubAuth({ app: "voting" })] } satisfies AuthConfig;
|
|
9
|
+
*
|
|
10
|
+
* Reads HUB_ISSUER and HUB_JWKS from the Convex deployment's environment
|
|
11
|
+
* (`bunx hub-user register` prints both). Then `ctx.auth.getUserIdentity()`
|
|
12
|
+
* returns the person: `subject` is their hub id, plus `name`, `team`, `role`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hubAuth({ app }: {
|
|
15
|
+
app: string;
|
|
16
|
+
}): Provider;
|
|
17
|
+
/**
|
|
18
|
+
* The signed-in person in a Convex query, mutation or action, or null.
|
|
19
|
+
* `id` is their hub id: key the app's data on it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getHubUser(ctx: {
|
|
22
|
+
auth: Auth;
|
|
23
|
+
}): Promise<HubUser | null>;
|
|
24
|
+
/** The signed-in person; throws "Sign in with your badge" otherwise. */
|
|
25
|
+
export declare function requireHubUser(ctx: {
|
|
26
|
+
auth: Auth;
|
|
27
|
+
}): Promise<HubUser>;
|
|
28
|
+
/** A signed-in facilitator; throws for anyone else. */
|
|
29
|
+
export declare function requireFacilitator(ctx: {
|
|
30
|
+
auth: Auth;
|
|
31
|
+
}): Promise<HubUser>;
|
package/dist/convex.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/convex.ts
|
|
2
|
+
import { ConvexError } from "convex/values";
|
|
3
|
+
function hubAuth({ app }) {
|
|
4
|
+
const issuer = process.env.HUB_ISSUER;
|
|
5
|
+
const jwks = process.env.HUB_JWKS;
|
|
6
|
+
if (!issuer || !jwks) {
|
|
7
|
+
throw new Error("Set HUB_ISSUER and HUB_JWKS on this Convex deployment: run `bunx hub-user register`");
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
type: "customJwt",
|
|
11
|
+
applicationID: app,
|
|
12
|
+
issuer,
|
|
13
|
+
jwks,
|
|
14
|
+
algorithm: "ES256"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function getHubUser(ctx) {
|
|
18
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
19
|
+
if (!identity) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
id: identity.subject,
|
|
24
|
+
name: typeof identity.name === "string" ? identity.name : undefined,
|
|
25
|
+
team: typeof identity.team === "string" ? identity.team : undefined,
|
|
26
|
+
role: identity.role === "facilitator" ? "facilitator" : "member"
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function requireHubUser(ctx) {
|
|
30
|
+
const user = await getHubUser(ctx);
|
|
31
|
+
if (!user) {
|
|
32
|
+
throw new ConvexError("Sign in with your badge");
|
|
33
|
+
}
|
|
34
|
+
return user;
|
|
35
|
+
}
|
|
36
|
+
async function requireFacilitator(ctx) {
|
|
37
|
+
const user = await requireHubUser(ctx);
|
|
38
|
+
if (user.role !== "facilitator") {
|
|
39
|
+
throw new ConvexError("Only facilitators can do this");
|
|
40
|
+
}
|
|
41
|
+
return user;
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
getHubUser,
|
|
45
|
+
hubAuth,
|
|
46
|
+
requireFacilitator,
|
|
47
|
+
requireHubUser
|
|
48
|
+
};
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type HubSession, type HubUser } from "./token";
|
|
3
|
+
export type { HubRole, HubUser } from "./token";
|
|
4
|
+
export type HubAuthProviderProps = {
|
|
5
|
+
app: string;
|
|
6
|
+
hubUrl?: string;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Provides the signed-in person to the app. Always renders its children:
|
|
11
|
+
* put it inside the root document's <body>, and use <HubSignedIn> to decide
|
|
12
|
+
* what to show. Apps on Convex use ConvexHubAuthProvider instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare function HubAuthProvider({ app, hubUrl, children, }: HubAuthProviderProps): import("react").JSX.Element;
|
|
15
|
+
/**
|
|
16
|
+
* Shows its children only to a signed-in person. Otherwise `signedOut`
|
|
17
|
+
* ("Scan your badge to sign in" by default), or `loading` while the session is
|
|
18
|
+
* being read or a dev server is on its way to the hub.
|
|
19
|
+
*/
|
|
20
|
+
export declare function HubSignedIn({ children, signedOut, loading, }: {
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
signedOut?: ReactNode;
|
|
23
|
+
loading?: ReactNode;
|
|
24
|
+
}): ReactNode;
|
|
25
|
+
/** The current session: undefined while loading, null when signed out. */
|
|
26
|
+
export declare function useHubSession(): HubSession | null | undefined;
|
|
27
|
+
/** The signed-in person, or null. */
|
|
28
|
+
export declare function useHubUser(): HubUser | null;
|
|
29
|
+
/**
|
|
30
|
+
* The raw token, for apps whose own server checks it with verifyHubToken:
|
|
31
|
+
* send it as `Authorization: Bearer <token>`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function useHubToken(): string | null;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HubAuthProvider2,
|
|
3
|
+
HubSignedIn2,
|
|
4
|
+
useHubSession2,
|
|
5
|
+
useHubUser2,
|
|
6
|
+
useHubToken2
|
|
7
|
+
} from "./convex-7n6dqf5r.js";
|
|
8
|
+
export {
|
|
9
|
+
HubAuthProvider2 as HubAuthProvider,
|
|
10
|
+
HubSignedIn2 as HubSignedIn,
|
|
11
|
+
useHubSession2 as useHubSession,
|
|
12
|
+
useHubToken2 as useHubToken,
|
|
13
|
+
useHubUser2 as useHubUser
|
|
14
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HubUser } from "./token";
|
|
2
|
+
export type { HubRole, HubUser } from "./token";
|
|
3
|
+
type Options = {
|
|
4
|
+
app: string;
|
|
5
|
+
issuer?: string;
|
|
6
|
+
jwks?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* For servers that are not Convex. Pass the request (token in an
|
|
10
|
+
* `Authorization: Bearer` header) or the token itself. Returns the person, or
|
|
11
|
+
* throws when the token is missing, expired, forged or meant for another app.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verifyHubToken(input: Request | string, { app, issuer, jwks, }: Options): Promise<HubUser>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import {
|
|
3
|
+
createLocalJWKSet,
|
|
4
|
+
createRemoteJWKSet,
|
|
5
|
+
jwtVerify
|
|
6
|
+
} from "jose";
|
|
7
|
+
var keySets = new Map;
|
|
8
|
+
function keySet(jwks, issuer) {
|
|
9
|
+
const source = jwks ?? `${issuer}/.well-known/jwks.json`;
|
|
10
|
+
let keys = keySets.get(source);
|
|
11
|
+
if (!keys) {
|
|
12
|
+
if (source.startsWith("data:")) {
|
|
13
|
+
const json = Buffer.from(source.split(",")[1] ?? "", "base64").toString();
|
|
14
|
+
keys = createLocalJWKSet(JSON.parse(json));
|
|
15
|
+
} else {
|
|
16
|
+
keys = createRemoteJWKSet(new URL(source));
|
|
17
|
+
}
|
|
18
|
+
keySets.set(source, keys);
|
|
19
|
+
}
|
|
20
|
+
return keys;
|
|
21
|
+
}
|
|
22
|
+
function bearer(input) {
|
|
23
|
+
if (typeof input === "string")
|
|
24
|
+
return input;
|
|
25
|
+
const header = input.headers.get("authorization") ?? "";
|
|
26
|
+
return header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
|
|
27
|
+
}
|
|
28
|
+
async function verifyHubToken(input, {
|
|
29
|
+
app,
|
|
30
|
+
issuer = process.env.HUB_ISSUER,
|
|
31
|
+
jwks = process.env.HUB_JWKS
|
|
32
|
+
}) {
|
|
33
|
+
if (!issuer) {
|
|
34
|
+
throw new Error("Set HUB_ISSUER: run `bunx hub-user register`");
|
|
35
|
+
}
|
|
36
|
+
const token = bearer(input);
|
|
37
|
+
if (!token) {
|
|
38
|
+
throw new Error("No hub token");
|
|
39
|
+
}
|
|
40
|
+
const { payload } = await jwtVerify(token, keySet(jwks, issuer), {
|
|
41
|
+
issuer,
|
|
42
|
+
audience: app,
|
|
43
|
+
algorithms: ["ES256"]
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
id: payload.sub,
|
|
47
|
+
name: payload.name,
|
|
48
|
+
team: payload.team,
|
|
49
|
+
role: payload.role === "facilitator" ? "facilitator" : "member"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
verifyHubToken
|
|
54
|
+
};
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type HubRole = "member" | "facilitator";
|
|
2
|
+
export type HubUser = {
|
|
3
|
+
id: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
team?: string;
|
|
6
|
+
role: HubRole;
|
|
7
|
+
};
|
|
8
|
+
export type HubClaims = HubUser & {
|
|
9
|
+
app: string;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function readClaims(token: string): HubClaims | null;
|
|
13
|
+
export type HubSession = {
|
|
14
|
+
token: string;
|
|
15
|
+
user: HubClaims;
|
|
16
|
+
};
|
|
17
|
+
export declare function sessionFor(token: string | null, app: string, now?: number): HubSession | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@user-hub/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign people in from User Hub badges: Convex auth config, React provider, server verification and the hub-user CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"skills"
|
|
9
|
+
],
|
|
10
|
+
"bin": {
|
|
11
|
+
"hub-user": "./dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./convex": {
|
|
15
|
+
"types": "./dist/convex.d.ts",
|
|
16
|
+
"default": "./dist/convex.js"
|
|
17
|
+
},
|
|
18
|
+
"./react": {
|
|
19
|
+
"types": "./dist/react.d.ts",
|
|
20
|
+
"default": "./dist/react.js"
|
|
21
|
+
},
|
|
22
|
+
"./server": {
|
|
23
|
+
"types": "./dist/server.d.ts",
|
|
24
|
+
"default": "./dist/server.js"
|
|
25
|
+
},
|
|
26
|
+
"./convex-react": {
|
|
27
|
+
"types": "./dist/convex-react.d.ts",
|
|
28
|
+
"default": "./dist/convex-react.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "rm -rf dist && bun build src/convex.ts src/react.tsx src/convex-react.tsx src/server.ts src/cli.ts --outdir dist --target node --format esm --splitting --packages external && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
33
|
+
"check": "biome check",
|
|
34
|
+
"test": "bun test",
|
|
35
|
+
"prepublishOnly": "bun run build && bun test"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"convex": ">=1.25.0",
|
|
39
|
+
"react": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"convex": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"react": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"jose": "^6.2.12"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@biomejs/biome": "2.5.15",
|
|
54
|
+
"@types/bun": "^1.4.2",
|
|
55
|
+
"@types/react": "^19.3.0",
|
|
56
|
+
"@types/react-dom": "^19.3.0",
|
|
57
|
+
"convex": "^1.46.0",
|
|
58
|
+
"react": "^19.3.0",
|
|
59
|
+
"react-dom": "^19.3.0",
|
|
60
|
+
"typescript": "^7.0.2"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"keywords": [
|
|
66
|
+
"user-hub",
|
|
67
|
+
"auth",
|
|
68
|
+
"badge",
|
|
69
|
+
"qr",
|
|
70
|
+
"convex",
|
|
71
|
+
"react"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: user-hub-auth
|
|
3
|
+
description: Sign people into an app from their User Hub badge. Use when an app needs User Hub auth (workshop badges, QR sign-in, hubAuth, ConvexHubAuthProvider, HubAuthProvider, HubSignedIn, useHubUser, verifyHubToken, hub-user register), or when asked to connect an app to the hub. Never hand-roll login, passwords or token checks for these apps.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# User Hub auth
|
|
7
|
+
|
|
8
|
+
People at a workshop scan a printed QR badge. The hub sends them to whichever app is live, signed in, with a token in the URL fragment (`#token=…`) that only that app accepts. This package turns that token into a signed-in person. The app keeps its own data, keyed on the person's hub id.
|
|
9
|
+
|
|
10
|
+
## The hub
|
|
11
|
+
|
|
12
|
+
Ask the human for the hub's URL. It is `HUB_URL` for `hub-user`, the `HUB_ISSUER` the app trusts and the `VITE_HUB_URL` its frontend uses.
|
|
13
|
+
|
|
14
|
+
## Before you start: ask the human
|
|
15
|
+
|
|
16
|
+
You need four things. Ask for them; never guess.
|
|
17
|
+
|
|
18
|
+
1. **The app's name** in the hub, unique to this app (the same name updates the same app, and apps copied from a template often share a package name).
|
|
19
|
+
2. **Its local URL** (usually `http://localhost:3000`) and **its production URL** if it is deployed. Without a production URL the app can be developed but not scheduled, so badge scans never reach it; add it later by running register again with `--prod`.
|
|
20
|
+
3. **Where `HUB_URL` and `HUB_ADMIN_PASSWORD` are.** You do not have the password, and you must never ask for it in chat, write it into a file yourself, commit it or print it. Ask the human which of these applies:
|
|
21
|
+
- **`.env` or `.env.local`** in the app's repo (must be gitignored): the human adds `HUB_URL=…` and `HUB_ADMIN_PASSWORD=…` themselves. `bunx hub-user` reads `.env` and `.env.local` automatically.
|
|
22
|
+
- **Infisical** (or another secret manager): run the command through it, for example `infisical run -- bunx hub-user register …`.
|
|
23
|
+
- **The human runs the command** and pastes you only the output, which contains no secrets.
|
|
24
|
+
4. **Which Convex deployments** the app uses (dev, prod), so the values go on each.
|
|
25
|
+
|
|
26
|
+
## Steps
|
|
27
|
+
|
|
28
|
+
1. **Install:**
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bun add @user-hub/auth
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Register the app** from the app's repo root, with the secrets supplied as the human chose in step 3 above:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
bunx hub-user register --name "Voting" --prod https://app.example.com --dev http://localhost:3000
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Leave out `--prod` if the app is not deployed yet. Running it again with the same `--name` updates the URLs; a URL left out keeps the registered one. If it fails with "set HUB_URL", "set HUB_ADMIN_PASSWORD" or a wrong password, stop and ask the human; do not work around it.
|
|
41
|
+
|
|
42
|
+
It prints the app **key** (made from the name), `HUB_ISSUER` and `HUB_JWKS` (server side) and `VITE_HUB_URL` (frontend). Add `--json` to parse them.
|
|
43
|
+
|
|
44
|
+
3. **Set the two values** where tokens are checked:
|
|
45
|
+
- Convex: `bunx convex env set HUB_ISSUER '…'` and `bunx convex env set HUB_JWKS '…'` (on every deployment the app uses).
|
|
46
|
+
- Other servers: their environment, same names.
|
|
47
|
+
|
|
48
|
+
4. **Wire it up** with the key from step 2.
|
|
49
|
+
|
|
50
|
+
Convex, `convex/auth.config.ts`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import type { AuthConfig } from "convex/server";
|
|
54
|
+
import { hubAuth } from "@user-hub/auth/convex";
|
|
55
|
+
|
|
56
|
+
export default { providers: [hubAuth({ app: "<key>" })] } satisfies AuthConfig;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The provider replaces `ConvexProvider` (keep the same `ConvexReactClient`). Set `VITE_HUB_URL` (the hub's URL) in the app's frontend env.
|
|
60
|
+
|
|
61
|
+
**Where it goes:** inside the root document's `<body>`, around the page content. In TanStack Start that is the root route's shell component (`src/routes/__root.tsx`); in a Vite SPA, around `<App />`; in Next.js, inside `<body>` in the root layout. **Never** in the router's `Wrap`, or anywhere outside `<html>`.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { ConvexHubAuthProvider, HubSignedIn } from "@user-hub/auth/convex-react";
|
|
65
|
+
|
|
66
|
+
<body>
|
|
67
|
+
<ConvexHubAuthProvider
|
|
68
|
+
client={convex}
|
|
69
|
+
app="<key>"
|
|
70
|
+
hubUrl={import.meta.env.VITE_HUB_URL}
|
|
71
|
+
>
|
|
72
|
+
<HubSignedIn>{children}</HubSignedIn>
|
|
73
|
+
</ConvexHubAuthProvider>
|
|
74
|
+
<Scripts />
|
|
75
|
+
</body>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The provider always renders its children; `<HubSignedIn>` shows them only to a signed-in person. Without a valid token it shows "Scan your badge to sign in": pass `signedOut={…}` to style that screen and `loading={…}` for the first render. Anything outside `<HubSignedIn>` (a public landing page, say) shows to everyone. Apps without Convex use `HubAuthProvider` and `HubSignedIn` from `@user-hub/auth/react`, with the same props minus `client`.
|
|
79
|
+
|
|
80
|
+
5. **Use the person.**
|
|
81
|
+
- In components: `const me = useHubUser()` gives `{ id, name, team, role }`.
|
|
82
|
+
- In Convex functions: `const me = await requireHubUser(ctx)` gives the same `{ id, name, team, role }` (see Facilitator pages).
|
|
83
|
+
- Not Convex: wrap the app in `HubAuthProvider` and `HubSignedIn` (`@user-hub/auth/react`), send `useHubToken()` as `Authorization: Bearer <token>`, and check it on the server with `await verifyHubToken(request, { app: "<key>" })` from `@user-hub/auth/server`.
|
|
84
|
+
|
|
85
|
+
## Your dev server
|
|
86
|
+
|
|
87
|
+
Run the app as usual (`bun run dev`) on the `--dev` URL you registered. Opening it with no token sends the browser to the hub (admin password once per browser), where you pick who to be; the hub sends you back signed in as them. The token is kept for 4 hours across reloads and restarts, then the same thing happens again.
|
|
88
|
+
|
|
89
|
+
- To switch person: open `<hub>/open/<key>` and pick someone else.
|
|
90
|
+
- If the dev server runs on another port, run `hub-user register` again with the same `--name` and the new `--dev` URL: the hub only sends tokens back to registered URLs.
|
|
91
|
+
- This only happens on local addresses (localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, *.local). In production a person without a token sees "Scan your badge to sign in".
|
|
92
|
+
|
|
93
|
+
## Facilitator pages
|
|
94
|
+
|
|
95
|
+
People are either `"member"` or `"facilitator"`, set in the hub. To give facilitators their own page or actions:
|
|
96
|
+
|
|
97
|
+
- **Protect the data on the server, always.** In Convex functions use the helpers from `@user-hub/auth/convex`:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { requireFacilitator, requireHubUser } from "@user-hub/auth/convex";
|
|
101
|
+
|
|
102
|
+
export const closeVoting = mutation({
|
|
103
|
+
args: {},
|
|
104
|
+
handler: async (ctx) => {
|
|
105
|
+
const facilitator = await requireFacilitator(ctx); // throws for members
|
|
106
|
+
// …
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const myVotes = query({
|
|
111
|
+
args: {},
|
|
112
|
+
handler: async (ctx) => {
|
|
113
|
+
const me = await requireHubUser(ctx); // throws when signed out
|
|
114
|
+
// query by me.id
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`getHubUser(ctx)` returns the person or null when signing in is optional. Not Convex: check `(await verifyHubToken(request, { app })).role`.
|
|
120
|
+
|
|
121
|
+
- **Then hide what members cannot use**, for a clean UI only:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
const me = useHubUser();
|
|
125
|
+
if (me?.role !== "facilitator") return <p>This page is for facilitators.</p>;
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A hidden page is not protection: members can still call the functions, which is why the server check comes first.
|
|
129
|
+
|
|
130
|
+
## Rules
|
|
131
|
+
|
|
132
|
+
- Key all data on the hub id (`identity.subject` / `useHubUser().id`). Never on the name, and never on the badge token, which the app never sees.
|
|
133
|
+
- Check access on the server: `requireFacilitator(ctx)` / `requireHubUser(ctx)` in Convex, or `verifyHubToken` elsewhere. `useHubUser()` is for display only.
|
|
134
|
+
- Tokens last 4 hours; the provider then shows the signed-out screen. Do not try to refresh them: people scan their badge again.
|
|
135
|
+
- Do not add other sign-in methods, sessions or token handling. If something is missing, change this package instead.
|