@pablozaiden/webapp 0.3.7 → 0.3.8
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/docs/auth.md +3 -1
- package/docs/settings.md +2 -0
- package/package.json +1 -1
- package/src/contracts/index.ts +1 -0
- package/src/server/auth/api-keys.ts +7 -1
- package/src/server/auth/device-auth.ts +17 -11
- package/src/server/auth/sqlite-store.ts +6 -0
- package/src/server/auth/store.ts +2 -0
- package/src/web/WebAppRoot.tsx +2 -2
package/docs/auth.md
CHANGED
|
@@ -40,6 +40,8 @@ PATCH: (_req, ctx) => {
|
|
|
40
40
|
|
|
41
41
|
API keys are user-owned bearer tokens for scripts and agents. They are stored hashed in SQLite and shown only once at creation. Route `scopes` are enforced for API-key requests; `*` grants all scopes.
|
|
42
42
|
|
|
43
|
+
Expired API keys do not authenticate, are omitted from user-facing lists, and are purged when key lists or expired keys are encountered.
|
|
44
|
+
|
|
43
45
|
Same-origin checks are skipped for API-key and device bearer requests unless a route sets `sameOrigin: "always"`, because non-browser clients usually do not send `Origin`.
|
|
44
46
|
|
|
45
47
|
## Device auth
|
|
@@ -52,7 +54,7 @@ Device auth is included in V1:
|
|
|
52
54
|
4. Client exchanges the approved code at `/api/auth/token`.
|
|
53
55
|
5. Access tokens are JWT bearer tokens whose `sub` is the approving user id; refresh tokens rotate on every refresh.
|
|
54
56
|
|
|
55
|
-
Device codes are one-use. Device sessions are self-only in Settings. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
|
|
57
|
+
Device codes are one-use. Device sessions are self-only in Settings, and only active refresh-token sessions are listed. Revoked or expired sessions are hidden; expired sessions are purged, while revoked session records can remain stored to detect stale refresh-token reuse. Reusing a consumed device code or stale refresh token returns `invalid_grant`.
|
|
56
58
|
|
|
57
59
|
## Same-origin policy
|
|
58
60
|
|
package/docs/settings.md
CHANGED
|
@@ -11,6 +11,8 @@ Settings is framework-owned so apps stay consistent. It includes:
|
|
|
11
11
|
- Admin server kill
|
|
12
12
|
- Version/about
|
|
13
13
|
|
|
14
|
+
Security lists only show useful active credentials. Expired API keys are purged before listing, and revoked or expired device-auth refresh sessions are not shown. Revoked refresh sessions may remain in storage when needed for token-reuse protection, but they are hidden from Settings.
|
|
15
|
+
|
|
14
16
|
Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
|
|
15
17
|
|
|
16
18
|
The server kill control follows the normal Settings row layout: explanation on the left, action on the right. After confirmation and a successful response, Settings shows a 15-second shutdown countdown progress bar that visibly drains before reloading the page.
|
package/package.json
CHANGED
package/src/contracts/index.ts
CHANGED
|
@@ -16,7 +16,10 @@ function summarize(record: { tokenHash?: string } & ApiKeySummary): ApiKeySummar
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function listApiKeys(store: WebAppStore, userId: string): ApiKeySummary[] {
|
|
19
|
-
|
|
19
|
+
store.deleteExpiredApiKeys?.(nowIso());
|
|
20
|
+
return store.listApiKeys(userId)
|
|
21
|
+
.filter((record) => !record.expiresAt || !isExpired(record.expiresAt))
|
|
22
|
+
.map(summarize);
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export function createApiKey(store: WebAppStore, user: CurrentUser, input: { name?: string; scopes?: string[]; prefix?: string; expiresAt?: string }): CreatedApiKeyResponse {
|
|
@@ -44,6 +47,9 @@ export function authenticateApiKey(store: WebAppStore, token: string): { user: C
|
|
|
44
47
|
const tokenHash = sha256(token);
|
|
45
48
|
const record = store.getApiKeyByHash(tokenHash);
|
|
46
49
|
if (!record || !secureEqual(record.tokenHash, tokenHash) || (record.expiresAt && isExpired(record.expiresAt))) {
|
|
50
|
+
if (record?.expiresAt && isExpired(record.expiresAt)) {
|
|
51
|
+
store.deleteApiKey(record.id);
|
|
52
|
+
}
|
|
47
53
|
return undefined;
|
|
48
54
|
}
|
|
49
55
|
const user = store.getUserById(record.userId);
|
|
@@ -123,6 +123,7 @@ function tokenSet(accessToken: string, refreshToken: string, scope: string): Tok
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function createDeviceAuthorization(req: Request, store: WebAppStore, config: RuntimeConfig, input: { clientId?: string; scope?: string } = {}): DeviceAuthorizationResponse {
|
|
126
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
126
127
|
const deviceCode = randomToken(32);
|
|
127
128
|
let userCode = generateUserCode();
|
|
128
129
|
while (store.getDeviceAuthByUserCode(userCode)) {
|
|
@@ -151,6 +152,7 @@ export function createDeviceAuthorization(req: Request, store: WebAppStore, conf
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
export function getDeviceVerificationDetails(store: WebAppStore, userCode: string, passkeyRequired: boolean): DeviceVerificationDetails {
|
|
155
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
154
156
|
const record = store.getDeviceAuthByUserCode(userCode);
|
|
155
157
|
if (!record || isExpired(record.expiresAt)) {
|
|
156
158
|
throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
|
|
@@ -207,6 +209,7 @@ function createRefreshRecord(userId: string, clientId: string, scope: string, fa
|
|
|
207
209
|
}
|
|
208
210
|
|
|
209
211
|
export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConfig, deviceCode: string, clientId?: string): Promise<TokenResponse> {
|
|
212
|
+
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
210
213
|
const record = store.getDeviceAuthByDeviceCodeHash(sha256(deviceCode));
|
|
211
214
|
if (!record) {
|
|
212
215
|
throw new AuthError("invalid_grant", "Device code is invalid", 400);
|
|
@@ -301,17 +304,20 @@ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfi
|
|
|
301
304
|
}
|
|
302
305
|
|
|
303
306
|
export function listAuthSessions(store: WebAppStore, userId: string): AuthSessionSummary[] {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
307
|
+
store.deleteExpiredRefreshSessions?.(nowIso());
|
|
308
|
+
return store.listRefreshSessions(userId)
|
|
309
|
+
.filter((session) => !session.revokedAt && !isExpired(session.expiresAt))
|
|
310
|
+
.map((session) => ({
|
|
311
|
+
id: session.id,
|
|
312
|
+
clientId: session.clientId,
|
|
313
|
+
scope: session.scope,
|
|
314
|
+
createdAt: session.createdAt,
|
|
315
|
+
updatedAt: session.updatedAt,
|
|
316
|
+
expiresAt: session.expiresAt,
|
|
317
|
+
lastUsedAt: session.lastUsedAt,
|
|
318
|
+
revokedAt: session.revokedAt,
|
|
319
|
+
active: true,
|
|
320
|
+
}));
|
|
315
321
|
}
|
|
316
322
|
|
|
317
323
|
export function revokeAuthSession(store: WebAppStore, userId: string, id: string): boolean {
|
|
@@ -560,6 +560,9 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
560
560
|
deleteApiKeysForUser: (userId) => {
|
|
561
561
|
db.query("DELETE FROM webapp_api_keys WHERE user_id = ?").run(userId);
|
|
562
562
|
},
|
|
563
|
+
deleteExpiredApiKeys: (now) => {
|
|
564
|
+
db.query("DELETE FROM webapp_api_keys WHERE expires_at IS NOT NULL AND expires_at <= ?").run(now);
|
|
565
|
+
},
|
|
563
566
|
|
|
564
567
|
saveDeviceAuthRequest: (record) => {
|
|
565
568
|
db.query(`
|
|
@@ -646,5 +649,8 @@ export function sqliteWebAppStore(options: { dataDir?: string; fileName?: string
|
|
|
646
649
|
revokeRefreshSessionsForUser: (userId, revokedAt) => {
|
|
647
650
|
db.query("UPDATE webapp_refresh_sessions SET revoked_at = ?, updated_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(revokedAt, revokedAt, userId);
|
|
648
651
|
},
|
|
652
|
+
deleteExpiredRefreshSessions: (now) => {
|
|
653
|
+
db.query("DELETE FROM webapp_refresh_sessions WHERE expires_at <= ?").run(now);
|
|
654
|
+
},
|
|
649
655
|
};
|
|
650
656
|
}
|
package/src/server/auth/store.ts
CHANGED
|
@@ -115,6 +115,7 @@ export interface WebAppStore {
|
|
|
115
115
|
touchApiKey(id: string, lastUsedAt: string): void;
|
|
116
116
|
deleteApiKey(id: string, userId?: string): boolean;
|
|
117
117
|
deleteApiKeysForUser(userId: string): void;
|
|
118
|
+
deleteExpiredApiKeys?(nowIso: string): void;
|
|
118
119
|
|
|
119
120
|
saveDeviceAuthRequest(record: DeviceAuthRequestRecord): void;
|
|
120
121
|
getDeviceAuthByUserCode(userCode: string): DeviceAuthRequestRecord | undefined;
|
|
@@ -132,4 +133,5 @@ export interface WebAppStore {
|
|
|
132
133
|
revokeRefreshSession(id: string, revokedAt: string, userId?: string): boolean;
|
|
133
134
|
revokeRefreshFamily(familyId: string, revokedAt: string): void;
|
|
134
135
|
revokeRefreshSessionsForUser(userId: string, revokedAt: string): void;
|
|
136
|
+
deleteExpiredRefreshSessions?(nowIso: string): void;
|
|
135
137
|
}
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -967,8 +967,8 @@ function SettingsView({ config, refresh, customSections, theme, setTheme }: { co
|
|
|
967
967
|
<div className="wapp-list">
|
|
968
968
|
{authSessions.length ? authSessions.map((session) => (
|
|
969
969
|
<div className="wapp-list-row" key={session.id}>
|
|
970
|
-
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.
|
|
971
|
-
<Button type="button" variant="danger"
|
|
970
|
+
<span><strong>{session.clientId}</strong><small>{session.scope} · {session.updatedAt}</small></span>
|
|
971
|
+
<Button type="button" variant="danger" onClick={() => setAuthSessionToRevoke(session)}>Revoke</Button>
|
|
972
972
|
</div>
|
|
973
973
|
)) : <EmptyState title="No device sessions" />}
|
|
974
974
|
</div>
|