@pablozaiden/webapp 0.5.8 → 0.6.1
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 +12 -4
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +33 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/index.ts +1 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +93 -1
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1214
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +76 -64
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { CurrentUser } from "../contracts";
|
|
2
|
+
import { authenticateApiKey, assertScopes } from "./auth/api-keys";
|
|
3
|
+
import {
|
|
4
|
+
getPasskeySessionUser,
|
|
5
|
+
isPasskeyAuthRequired,
|
|
6
|
+
} from "./auth/passkeys";
|
|
7
|
+
import { verifyAccessToken } from "./auth/device-auth";
|
|
8
|
+
import { createUserRecord } from "./auth/users";
|
|
9
|
+
import type { WebAppStore } from "./auth/store";
|
|
10
|
+
import { AuthError, type AuthenticatedRequestState } from "./auth/types";
|
|
11
|
+
import { errorResponse, jsonResponse, requestBodyErrorResponse } from "./responses";
|
|
12
|
+
import type { RuntimeConfig } from "./runtime-config";
|
|
13
|
+
import type { RouteAuth, UserIdSelector, UserOwnedResource } from "./routes";
|
|
14
|
+
|
|
15
|
+
export interface AuthenticationDependencies {
|
|
16
|
+
store: WebAppStore;
|
|
17
|
+
config: RuntimeConfig;
|
|
18
|
+
passkeysEnabled: boolean;
|
|
19
|
+
apiKeysEnabled: boolean;
|
|
20
|
+
deviceAuthEnabled: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Authentication {
|
|
24
|
+
authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response>;
|
|
25
|
+
configUser(req: Request): CurrentUser | undefined;
|
|
26
|
+
currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined;
|
|
27
|
+
requireUser(auth: AuthenticatedRequestState): CurrentUser;
|
|
28
|
+
requireAdmin(auth: AuthenticatedRequestState): CurrentUser;
|
|
29
|
+
requireOwner(auth: AuthenticatedRequestState): CurrentUser;
|
|
30
|
+
assertUser(auth: AuthenticatedRequestState, userId: string): CurrentUser;
|
|
31
|
+
createFilterOwned(auth: AuthenticatedRequestState): <TResource extends UserOwnedResource>(resources: readonly TResource[]) => TResource[];
|
|
32
|
+
createRequireOwned(auth: AuthenticatedRequestState): <TResource extends UserOwnedResource>(resource: TResource | null | undefined) => TResource;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bearerToken(req: Request): string | undefined {
|
|
36
|
+
const header = req.headers.get("authorization")?.trim();
|
|
37
|
+
if (!header) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
const [scheme, token] = header.split(/\s+/, 2);
|
|
41
|
+
return scheme?.toLowerCase() === "bearer" ? token : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scopesFromBearer(claims: { scope: string }): string[] {
|
|
45
|
+
return claims.scope.split(/\s+/).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toCurrentUserRecord(user: { id: string; username: string; role: "owner" | "admin" | "user" }): CurrentUser {
|
|
49
|
+
return {
|
|
50
|
+
id: user.id,
|
|
51
|
+
username: user.username,
|
|
52
|
+
role: user.role,
|
|
53
|
+
isOwner: user.role === "owner",
|
|
54
|
+
isAdmin: user.role === "owner" || user.role === "admin",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ownedUserId<TResource>(resource: TResource, getUserId?: UserIdSelector<TResource>): string | undefined {
|
|
59
|
+
if (getUserId) {
|
|
60
|
+
return getUserId(resource);
|
|
61
|
+
}
|
|
62
|
+
if (typeof resource === "object" && resource !== null && "userId" in resource && typeof resource.userId === "string") {
|
|
63
|
+
return resource.userId;
|
|
64
|
+
}
|
|
65
|
+
throw new AuthError("route_misconfigured", "Owned resource helpers require a string userId field or a getUserId selector", 500);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function requireOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined): TResource;
|
|
69
|
+
function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
|
|
70
|
+
function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
|
|
71
|
+
const user = requireUser(auth);
|
|
72
|
+
const resourceUserId = resource ? ownedUserId(resource, getUserId) : undefined;
|
|
73
|
+
if (!resource || resourceUserId !== user.id) {
|
|
74
|
+
throw new AuthError("not_found", "Resource not found", 404);
|
|
75
|
+
}
|
|
76
|
+
return resource;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function filterOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resources: readonly TResource[]): TResource[];
|
|
80
|
+
function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
|
|
81
|
+
function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
|
|
82
|
+
const user = requireUser(auth);
|
|
83
|
+
return resources.filter((resource) => ownedUserId(resource, getUserId) === user.id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
|
|
87
|
+
return auth.kind === "anonymous" ? undefined : auth.user;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireUser(auth: AuthenticatedRequestState): CurrentUser {
|
|
91
|
+
const user = currentUser(auth);
|
|
92
|
+
if (!user) {
|
|
93
|
+
throw new AuthError("authentication_required", "Authentication is required", 401);
|
|
94
|
+
}
|
|
95
|
+
return user;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireAdmin(auth: AuthenticatedRequestState): CurrentUser {
|
|
99
|
+
const user = requireUser(auth);
|
|
100
|
+
if (!user.isAdmin) {
|
|
101
|
+
throw new AuthError("admin_required", "Admin permissions are required", 403);
|
|
102
|
+
}
|
|
103
|
+
return user;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function requireOwner(auth: AuthenticatedRequestState): CurrentUser {
|
|
107
|
+
const user = requireUser(auth);
|
|
108
|
+
if (!user.isOwner) {
|
|
109
|
+
throw new AuthError("owner_required", "Owner permissions are required", 403);
|
|
110
|
+
}
|
|
111
|
+
return user;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assertUser(auth: AuthenticatedRequestState, userId: string): CurrentUser {
|
|
115
|
+
const user = requireUser(auth);
|
|
116
|
+
if (user.id !== userId) {
|
|
117
|
+
throw new AuthError("forbidden", "You cannot access another user's resource", 403);
|
|
118
|
+
}
|
|
119
|
+
return user;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createFilterOwned(auth: AuthenticatedRequestState) {
|
|
123
|
+
function contextFilterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
|
|
124
|
+
function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
|
|
125
|
+
function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
|
|
126
|
+
return filterOwned(auth, resources, getUserId as UserIdSelector<TResource>);
|
|
127
|
+
}
|
|
128
|
+
return contextFilterOwned;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function createRequireOwned(auth: AuthenticatedRequestState) {
|
|
132
|
+
function contextRequireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
|
|
133
|
+
function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
|
|
134
|
+
function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
|
|
135
|
+
return requireOwned(auth, resource, getUserId as UserIdSelector<TResource>);
|
|
136
|
+
}
|
|
137
|
+
return contextRequireOwned;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function requiresAuth(routeAuth: RouteAuth): boolean {
|
|
141
|
+
return routeAuth !== "public" && routeAuth !== "optional";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function enforceRouteAuth(routeAuth: RouteAuth, auth: AuthenticatedRequestState, authentication: Authentication): void {
|
|
145
|
+
if (routeAuth === "user") {
|
|
146
|
+
authentication.requireUser(auth);
|
|
147
|
+
} else if (routeAuth === "admin") {
|
|
148
|
+
authentication.requireAdmin(auth);
|
|
149
|
+
} else if (routeAuth === "owner") {
|
|
150
|
+
authentication.requireOwner(auth);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function tokenError(error: unknown): Response {
|
|
155
|
+
if (error instanceof AuthError) {
|
|
156
|
+
return jsonResponse({ error: error.code, error_description: error.message }, { status: error.status });
|
|
157
|
+
}
|
|
158
|
+
return jsonResponse({ error: "server_error", error_description: "An unexpected auth error occurred" }, { status: 500 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function authErrorResponse(error: unknown): Response {
|
|
162
|
+
const requestBodyFailure = requestBodyErrorResponse(error);
|
|
163
|
+
if (requestBodyFailure) {
|
|
164
|
+
return requestBodyFailure;
|
|
165
|
+
}
|
|
166
|
+
if (error instanceof AuthError) {
|
|
167
|
+
return errorResponse(error.status, error.code, error.message);
|
|
168
|
+
}
|
|
169
|
+
return errorResponse(500, "request_failed", "Request failed");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createAuthentication(dependencies: AuthenticationDependencies): Authentication {
|
|
173
|
+
const { store, config, passkeysEnabled, apiKeysEnabled, deviceAuthEnabled } = dependencies;
|
|
174
|
+
|
|
175
|
+
function disabledAuthOwner(): CurrentUser {
|
|
176
|
+
const existing = store.getOwnerUser();
|
|
177
|
+
if (existing) {
|
|
178
|
+
return toCurrentUserRecord(existing);
|
|
179
|
+
}
|
|
180
|
+
const owner = createUserRecord({ username: "admin", role: "owner" });
|
|
181
|
+
store.createUser(owner);
|
|
182
|
+
return toCurrentUserRecord(owner);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response> {
|
|
186
|
+
const token = bearerToken(req);
|
|
187
|
+
if (token) {
|
|
188
|
+
if (deviceAuthEnabled) {
|
|
189
|
+
try {
|
|
190
|
+
const claims = await verifyAccessToken(store, config, token);
|
|
191
|
+
const user = store.getUserById(claims.sub);
|
|
192
|
+
if (user) {
|
|
193
|
+
return {
|
|
194
|
+
kind: "bearer",
|
|
195
|
+
claims,
|
|
196
|
+
user: {
|
|
197
|
+
id: user.id,
|
|
198
|
+
username: user.username,
|
|
199
|
+
role: user.role,
|
|
200
|
+
isOwner: user.role === "owner",
|
|
201
|
+
isAdmin: user.role === "owner" || user.role === "admin",
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// Fall through to API keys.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (apiKeysEnabled) {
|
|
210
|
+
const apiKey = authenticateApiKey(store, token);
|
|
211
|
+
if (apiKey) {
|
|
212
|
+
return { kind: "api-key", ...apiKey };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return errorResponse(401, "invalid_token", "Invalid authentication token");
|
|
216
|
+
}
|
|
217
|
+
if (passkeysEnabled) {
|
|
218
|
+
if (config.passkeyDisabled) {
|
|
219
|
+
return { kind: "passkey", user: disabledAuthOwner() };
|
|
220
|
+
}
|
|
221
|
+
const user = getPasskeySessionUser(req, store, config);
|
|
222
|
+
if (user) {
|
|
223
|
+
return { kind: "passkey", user };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (passkeysEnabled && isPasskeyAuthRequired(store, config)) {
|
|
227
|
+
const user = getPasskeySessionUser(req, store, config);
|
|
228
|
+
if (!user) {
|
|
229
|
+
return required ? errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
|
|
230
|
+
headers: { "x-webapp-passkey-required": "true" },
|
|
231
|
+
}) : { kind: "anonymous" };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (required && passkeysEnabled && !config.passkeyDisabled && store.countUsers() === 0) {
|
|
235
|
+
return errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
|
|
236
|
+
headers: { "x-webapp-passkey-required": "true" },
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return { kind: "anonymous" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function configUser(req: Request): CurrentUser | undefined {
|
|
243
|
+
if (!passkeysEnabled) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
if (config.passkeyDisabled) {
|
|
247
|
+
return disabledAuthOwner();
|
|
248
|
+
}
|
|
249
|
+
return getPasskeySessionUser(req, store, config);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
authorize,
|
|
254
|
+
configUser,
|
|
255
|
+
currentUser,
|
|
256
|
+
requireUser,
|
|
257
|
+
requireAdmin,
|
|
258
|
+
requireOwner,
|
|
259
|
+
assertUser,
|
|
260
|
+
createFilterOwned,
|
|
261
|
+
createRequireOwned,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { assertScopes };
|