@digitalpaws/starr 0.0.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.
Files changed (290) hide show
  1. package/README.rst +75 -0
  2. package/data/.nfs.811bbb0d.43c34.4 +0 -0
  3. package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
  4. package/data/.nfs.82d127f7.fffff5704.4 +0 -0
  5. package/data/.nfs.82d127fe.fffff5704.4 +0 -0
  6. package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
  7. package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
  8. package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
  9. package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
  10. package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
  11. package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
  12. package/data/.nfs.82d18536.fffff5e24.4 +0 -0
  13. package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
  14. package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
  15. package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
  16. package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
  17. package/data/.nfs.8314122e.fffffb044.4 +0 -0
  18. package/data/.nfs.83141236.fffffb044.4 +0 -0
  19. package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
  20. package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
  21. package/data/logs/starr.log +66244 -0
  22. package/data/starr.db +0 -0
  23. package/data/starr.db-shm +0 -0
  24. package/data/starr.db-wal +0 -0
  25. package/data/starr.jwt +1 -0
  26. package/data/starr.key +1 -0
  27. package/dist/auth/auth-service.d.ts +42 -0
  28. package/dist/auth/auth-service.d.ts.map +1 -0
  29. package/dist/auth/auth-service.js +226 -0
  30. package/dist/auth/auth-service.js.map +1 -0
  31. package/dist/auth/http-server.d.ts +16 -0
  32. package/dist/auth/http-server.d.ts.map +1 -0
  33. package/dist/auth/http-server.js +207 -0
  34. package/dist/auth/http-server.js.map +1 -0
  35. package/dist/auth/jwt-secret.d.ts +6 -0
  36. package/dist/auth/jwt-secret.d.ts.map +1 -0
  37. package/dist/auth/jwt-secret.js +51 -0
  38. package/dist/auth/jwt-secret.js.map +1 -0
  39. package/dist/auth/oidc/oidc-flow.d.ts +60 -0
  40. package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
  41. package/dist/auth/oidc/oidc-flow.js +201 -0
  42. package/dist/auth/oidc/oidc-flow.js.map +1 -0
  43. package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
  44. package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
  45. package/dist/auth/oidc/oidc-provider-service.js +124 -0
  46. package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
  47. package/dist/auth/oidc/user-service.d.ts +67 -0
  48. package/dist/auth/oidc/user-service.d.ts.map +1 -0
  49. package/dist/auth/oidc/user-service.js +177 -0
  50. package/dist/auth/oidc/user-service.js.map +1 -0
  51. package/dist/auth/rate-limiter.d.ts +31 -0
  52. package/dist/auth/rate-limiter.d.ts.map +1 -0
  53. package/dist/auth/rate-limiter.js +70 -0
  54. package/dist/auth/rate-limiter.js.map +1 -0
  55. package/dist/config/config-service.d.ts +83 -0
  56. package/dist/config/config-service.d.ts.map +1 -0
  57. package/dist/config/config-service.js +309 -0
  58. package/dist/config/config-service.js.map +1 -0
  59. package/dist/config/validate.d.ts +12 -0
  60. package/dist/config/validate.d.ts.map +1 -0
  61. package/dist/config/validate.js +140 -0
  62. package/dist/config/validate.js.map +1 -0
  63. package/dist/content-types/registry.d.ts +75 -0
  64. package/dist/content-types/registry.d.ts.map +1 -0
  65. package/dist/content-types/registry.js +171 -0
  66. package/dist/content-types/registry.js.map +1 -0
  67. package/dist/datastore/db.d.ts +9 -0
  68. package/dist/datastore/db.d.ts.map +1 -0
  69. package/dist/datastore/db.js +27 -0
  70. package/dist/datastore/db.js.map +1 -0
  71. package/dist/datastore/encrypt.d.ts +9 -0
  72. package/dist/datastore/encrypt.d.ts.map +1 -0
  73. package/dist/datastore/encrypt.js +84 -0
  74. package/dist/datastore/encrypt.js.map +1 -0
  75. package/dist/datastore/migrations.d.ts +10 -0
  76. package/dist/datastore/migrations.d.ts.map +1 -0
  77. package/dist/datastore/migrations.js +340 -0
  78. package/dist/datastore/migrations.js.map +1 -0
  79. package/dist/datastore/storage.d.ts +4 -0
  80. package/dist/datastore/storage.d.ts.map +1 -0
  81. package/dist/datastore/storage.js +60 -0
  82. package/dist/datastore/storage.js.map +1 -0
  83. package/dist/events/event-bus.d.ts +15 -0
  84. package/dist/events/event-bus.d.ts.map +1 -0
  85. package/dist/events/event-bus.js +140 -0
  86. package/dist/events/event-bus.js.map +1 -0
  87. package/dist/events/spine.d.ts +31 -0
  88. package/dist/events/spine.d.ts.map +1 -0
  89. package/dist/events/spine.js +68 -0
  90. package/dist/events/spine.js.map +1 -0
  91. package/dist/events/trace.d.ts +24 -0
  92. package/dist/events/trace.d.ts.map +1 -0
  93. package/dist/events/trace.js +33 -0
  94. package/dist/events/trace.js.map +1 -0
  95. package/dist/host.d.ts +65 -0
  96. package/dist/host.d.ts.map +1 -0
  97. package/dist/host.js +94 -0
  98. package/dist/host.js.map +1 -0
  99. package/dist/library/importer.d.ts +29 -0
  100. package/dist/library/importer.d.ts.map +1 -0
  101. package/dist/library/importer.js +145 -0
  102. package/dist/library/importer.js.map +1 -0
  103. package/dist/library/library-cache.d.ts +46 -0
  104. package/dist/library/library-cache.d.ts.map +1 -0
  105. package/dist/library/library-cache.js +152 -0
  106. package/dist/library/library-cache.js.map +1 -0
  107. package/dist/library/library-config.d.ts +12 -0
  108. package/dist/library/library-config.d.ts.map +1 -0
  109. package/dist/library/library-config.js +10 -0
  110. package/dist/library/library-config.js.map +1 -0
  111. package/dist/library/library-scanner.d.ts +62 -0
  112. package/dist/library/library-scanner.d.ts.map +1 -0
  113. package/dist/library/library-scanner.js +336 -0
  114. package/dist/library/library-scanner.js.map +1 -0
  115. package/dist/library/library-service.d.ts +65 -0
  116. package/dist/library/library-service.d.ts.map +1 -0
  117. package/dist/library/library-service.js +197 -0
  118. package/dist/library/library-service.js.map +1 -0
  119. package/dist/library/metadata-matcher.d.ts +56 -0
  120. package/dist/library/metadata-matcher.d.ts.map +1 -0
  121. package/dist/library/metadata-matcher.js +207 -0
  122. package/dist/library/metadata-matcher.js.map +1 -0
  123. package/dist/library/naming.d.ts +8 -0
  124. package/dist/library/naming.d.ts.map +1 -0
  125. package/dist/library/naming.js +47 -0
  126. package/dist/library/naming.js.map +1 -0
  127. package/dist/library/scan-controller.d.ts +95 -0
  128. package/dist/library/scan-controller.d.ts.map +1 -0
  129. package/dist/library/scan-controller.js +379 -0
  130. package/dist/library/scan-controller.js.map +1 -0
  131. package/dist/library/source-resolver.d.ts +17 -0
  132. package/dist/library/source-resolver.d.ts.map +1 -0
  133. package/dist/library/source-resolver.js +75 -0
  134. package/dist/library/source-resolver.js.map +1 -0
  135. package/dist/logging/logger.d.ts +3 -0
  136. package/dist/logging/logger.d.ts.map +1 -0
  137. package/dist/logging/logger.js +107 -0
  138. package/dist/logging/logger.js.map +1 -0
  139. package/dist/main.d.ts +36 -0
  140. package/dist/main.d.ts.map +1 -0
  141. package/dist/main.js +229 -0
  142. package/dist/main.js.map +1 -0
  143. package/dist/plugins/context.d.ts +10 -0
  144. package/dist/plugins/context.d.ts.map +1 -0
  145. package/dist/plugins/context.js +110 -0
  146. package/dist/plugins/context.js.map +1 -0
  147. package/dist/plugins/discovery.d.ts +2 -0
  148. package/dist/plugins/discovery.d.ts.map +1 -0
  149. package/dist/plugins/discovery.js +32 -0
  150. package/dist/plugins/discovery.js.map +1 -0
  151. package/dist/plugins/loader.d.ts +9 -0
  152. package/dist/plugins/loader.d.ts.map +1 -0
  153. package/dist/plugins/loader.js +245 -0
  154. package/dist/plugins/loader.js.map +1 -0
  155. package/dist/state/request-intake.d.ts +26 -0
  156. package/dist/state/request-intake.d.ts.map +1 -0
  157. package/dist/state/request-intake.js +74 -0
  158. package/dist/state/request-intake.js.map +1 -0
  159. package/dist/state/state-store.d.ts +146 -0
  160. package/dist/state/state-store.d.ts.map +1 -0
  161. package/dist/state/state-store.js +465 -0
  162. package/dist/state/state-store.js.map +1 -0
  163. package/dist/state/trace-store.d.ts +12 -0
  164. package/dist/state/trace-store.d.ts.map +1 -0
  165. package/dist/state/trace-store.js +24 -0
  166. package/dist/state/trace-store.js.map +1 -0
  167. package/dist/types/plugin.d.ts +93 -0
  168. package/dist/types/plugin.d.ts.map +1 -0
  169. package/dist/types/plugin.js +4 -0
  170. package/dist/types/plugin.js.map +1 -0
  171. package/dist/web/index.d.ts +10 -0
  172. package/dist/web/index.d.ts.map +1 -0
  173. package/dist/web/index.js +153 -0
  174. package/dist/web/index.js.map +1 -0
  175. package/dist/web/routes/activity.d.ts +6 -0
  176. package/dist/web/routes/activity.d.ts.map +1 -0
  177. package/dist/web/routes/activity.js +31 -0
  178. package/dist/web/routes/activity.js.map +1 -0
  179. package/dist/web/routes/auth.d.ts +6 -0
  180. package/dist/web/routes/auth.d.ts.map +1 -0
  181. package/dist/web/routes/auth.js +142 -0
  182. package/dist/web/routes/auth.js.map +1 -0
  183. package/dist/web/routes/config.d.ts +7 -0
  184. package/dist/web/routes/config.d.ts.map +1 -0
  185. package/dist/web/routes/config.js +204 -0
  186. package/dist/web/routes/config.js.map +1 -0
  187. package/dist/web/routes/content-types.d.ts +6 -0
  188. package/dist/web/routes/content-types.d.ts.map +1 -0
  189. package/dist/web/routes/content-types.js +29 -0
  190. package/dist/web/routes/content-types.js.map +1 -0
  191. package/dist/web/routes/discovery.d.ts +6 -0
  192. package/dist/web/routes/discovery.d.ts.map +1 -0
  193. package/dist/web/routes/discovery.js +111 -0
  194. package/dist/web/routes/discovery.js.map +1 -0
  195. package/dist/web/routes/library-import.d.ts +6 -0
  196. package/dist/web/routes/library-import.d.ts.map +1 -0
  197. package/dist/web/routes/library-import.js +120 -0
  198. package/dist/web/routes/library-import.js.map +1 -0
  199. package/dist/web/routes/library-management.d.ts +6 -0
  200. package/dist/web/routes/library-management.d.ts.map +1 -0
  201. package/dist/web/routes/library-management.js +120 -0
  202. package/dist/web/routes/library-management.js.map +1 -0
  203. package/dist/web/routes/library.d.ts +6 -0
  204. package/dist/web/routes/library.d.ts.map +1 -0
  205. package/dist/web/routes/library.js +28 -0
  206. package/dist/web/routes/library.js.map +1 -0
  207. package/dist/web/routes/oidc.d.ts +9 -0
  208. package/dist/web/routes/oidc.d.ts.map +1 -0
  209. package/dist/web/routes/oidc.js +203 -0
  210. package/dist/web/routes/oidc.js.map +1 -0
  211. package/dist/web/routes/requests.d.ts +6 -0
  212. package/dist/web/routes/requests.d.ts.map +1 -0
  213. package/dist/web/routes/requests.js +183 -0
  214. package/dist/web/routes/requests.js.map +1 -0
  215. package/dist/web/routes/sse.d.ts +6 -0
  216. package/dist/web/routes/sse.d.ts.map +1 -0
  217. package/dist/web/routes/sse.js +54 -0
  218. package/dist/web/routes/sse.js.map +1 -0
  219. package/package.json +33 -0
  220. package/package.json.license +2 -0
  221. package/src/auth/auth-service.ts +333 -0
  222. package/src/auth/http-server.ts +249 -0
  223. package/src/auth/jwt-secret.ts +65 -0
  224. package/src/auth/oidc/oidc-flow.ts +266 -0
  225. package/src/auth/oidc/oidc-provider-service.ts +174 -0
  226. package/src/auth/oidc/user-service.ts +227 -0
  227. package/src/auth/rate-limiter.ts +85 -0
  228. package/src/config/config-service.ts +434 -0
  229. package/src/config/validate.ts +153 -0
  230. package/src/content-types/registry.ts +194 -0
  231. package/src/datastore/db.ts +38 -0
  232. package/src/datastore/encrypt.ts +107 -0
  233. package/src/datastore/migrations.ts +367 -0
  234. package/src/datastore/storage.ts +83 -0
  235. package/src/events/event-bus.ts +180 -0
  236. package/src/events/spine.ts +87 -0
  237. package/src/events/trace.ts +38 -0
  238. package/src/host.ts +130 -0
  239. package/src/library/importer.ts +174 -0
  240. package/src/library/library-cache.ts +212 -0
  241. package/src/library/library-config.ts +22 -0
  242. package/src/library/library-scanner.ts +404 -0
  243. package/src/library/library-service.ts +282 -0
  244. package/src/library/metadata-matcher.ts +285 -0
  245. package/src/library/naming.ts +59 -0
  246. package/src/library/scan-controller.ts +490 -0
  247. package/src/library/source-resolver.ts +92 -0
  248. package/src/logging/logger.ts +115 -0
  249. package/src/main.ts +364 -0
  250. package/src/plugins/context.ts +138 -0
  251. package/src/plugins/discovery.ts +29 -0
  252. package/src/plugins/loader.ts +309 -0
  253. package/src/state/request-intake.ts +101 -0
  254. package/src/state/state-store.ts +621 -0
  255. package/src/state/trace-store.ts +27 -0
  256. package/src/types/plugin.ts +110 -0
  257. package/src/web/index.ts +182 -0
  258. package/src/web/routes/activity.ts +41 -0
  259. package/src/web/routes/auth.ts +170 -0
  260. package/src/web/routes/config.ts +282 -0
  261. package/src/web/routes/content-types.ts +42 -0
  262. package/src/web/routes/discovery.ts +156 -0
  263. package/src/web/routes/library-import.ts +196 -0
  264. package/src/web/routes/library-management.ts +140 -0
  265. package/src/web/routes/library.ts +46 -0
  266. package/src/web/routes/oidc.ts +260 -0
  267. package/src/web/routes/requests.ts +250 -0
  268. package/src/web/routes/sse.ts +92 -0
  269. package/tests/auth.test.ts +510 -0
  270. package/tests/config-route.test.ts +345 -0
  271. package/tests/config.test.ts +612 -0
  272. package/tests/content-types.test.ts +376 -0
  273. package/tests/datastore.test.ts +345 -0
  274. package/tests/e2e.test.ts +430 -0
  275. package/tests/event-bus.test.ts +925 -0
  276. package/tests/host.test.ts +449 -0
  277. package/tests/library-cache.test.ts +171 -0
  278. package/tests/library-scanner.test.ts +177 -0
  279. package/tests/library.test.ts +210 -0
  280. package/tests/loader-context.test.ts +644 -0
  281. package/tests/logger-jsonl.test.ts +154 -0
  282. package/tests/metadata-matcher.test.ts +198 -0
  283. package/tests/oidc.test.ts +218 -0
  284. package/tests/scan-controller.test.ts +328 -0
  285. package/tests/source-resolver.test.ts +100 -0
  286. package/tests/starr-video-integration.test.ts +153 -0
  287. package/tests/state-store.test.ts +700 -0
  288. package/tests/trace-history.test.ts +356 -0
  289. package/tsconfig.json +8 -0
  290. package/tsconfig.json.license +2 -0
@@ -0,0 +1,266 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import {
5
+ discovery,
6
+ buildAuthorizationUrl,
7
+ authorizationCodeGrant,
8
+ randomPKCECodeVerifier,
9
+ calculatePKCECodeChallenge,
10
+ randomState,
11
+ randomNonce,
12
+ } from "openid-client";
13
+ import type Database from "better-sqlite3";
14
+ import type { Logger } from "../../types/plugin.js";
15
+ import type { OidcProviderService } from "./oidc-provider-service.js";
16
+ import type { User, UserService } from "./user-service.js";
17
+
18
+ const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
19
+
20
+ /** Result of a successful OIDC callback. */
21
+ export interface OidcCallbackResult {
22
+ user: User;
23
+ providerId: string;
24
+ created: boolean;
25
+ redirectTo: string;
26
+ }
27
+
28
+ export class OidcFlow {
29
+ constructor(
30
+ private db: Database.Database,
31
+ private providerService: OidcProviderService,
32
+ private userService: UserService,
33
+ private baseUrl: string, // e.g. "http://localhost:3457"
34
+ private logger: Logger,
35
+ ) {}
36
+
37
+ /**
38
+ * Stage 1 — Initiate the OIDC login flow.
39
+ *
40
+ * 1. Look up the provider configuration.
41
+ * 2. Discover the OIDC issuer's endpoints (via .well-known).
42
+ * 3. Generate PKCE code verifier and challenge, state, nonce.
43
+ * 4. Store the state in oidc_auth_states.
44
+ * 5. Return the redirect URL to send the user's browser to.
45
+ *
46
+ * @param providerId The OIDC provider's UUID.
47
+ * @param redirectTo Where to send the user after successful login (e.g. "/browse").
48
+ * @returns The authorization URL to redirect the browser to.
49
+ */
50
+ async initiateLogin(
51
+ providerId: string,
52
+ redirectTo: string = "/browse",
53
+ ): Promise<string> {
54
+ const provider = this.providerService.get(providerId);
55
+ if (!provider) {
56
+ throw new Error(`OIDC provider not found: ${providerId}`);
57
+ }
58
+ if (!provider.enabled) {
59
+ throw new Error(`OIDC provider is disabled: ${providerId}`);
60
+ }
61
+
62
+ // Discover the OIDC issuer's metadata
63
+ const config = await discovery(
64
+ new URL(provider.issuerUrl),
65
+ provider.clientId,
66
+ { client_secret: provider.clientSecret },
67
+ );
68
+
69
+ // Generate PKCE and state values
70
+ const codeVerifier = randomPKCECodeVerifier();
71
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
72
+ const state = randomState();
73
+ const nonce = randomNonce();
74
+
75
+ // Store the auth state
76
+ this.db.prepare(`
77
+ INSERT INTO oidc_auth_states (state, nonce, code_verifier, provider_id, redirect_to, created_at)
78
+ VALUES (@state, @nonce, @codeVerifier, @providerId, @redirectTo, datetime('now'))
79
+ `).run({
80
+ state,
81
+ nonce,
82
+ codeVerifier,
83
+ providerId: provider.id,
84
+ redirectTo,
85
+ });
86
+
87
+ // Build the authorization URL
88
+ const authUrl = buildAuthorizationUrl(config, {
89
+ redirect_uri: `${this.baseUrl}/api/oidc/callback/${provider.id}`,
90
+ scope: provider.scopes,
91
+ state,
92
+ nonce,
93
+ code_challenge: codeChallenge,
94
+ code_challenge_method: "S256",
95
+ response_type: "code",
96
+ });
97
+
98
+ this.logger.info("OIDC login initiated", {
99
+ providerId: provider.id,
100
+ providerName: provider.name,
101
+ });
102
+
103
+ return authUrl.href;
104
+ }
105
+
106
+ /**
107
+ * Stage 2 — Handle the OIDC callback.
108
+ *
109
+ * 1. Validate the state (CSRF protection).
110
+ * 2. Look up stored auth state from DB.
111
+ * 3. Exchange authorization code for tokens.
112
+ * 4. Validate the ID token (handled by openid-client).
113
+ * 5. Find or create the user.
114
+ * 6. Clean up the used auth state.
115
+ *
116
+ * @returns The authenticated user and whether they were just created.
117
+ */
118
+ async handleCallback(
119
+ providerId: string,
120
+ code: string,
121
+ state: string,
122
+ ): Promise<OidcCallbackResult> {
123
+ // 1. Validate the state (CSRF)
124
+ if (!state || !code) {
125
+ throw new Error("Missing state or code parameter");
126
+ }
127
+
128
+ // 2. Look up the stored auth state
129
+ const authState = this.db.prepare(`
130
+ SELECT * FROM oidc_auth_states WHERE state = @state AND provider_id = @providerId
131
+ `).get({ state, providerId }) as any;
132
+
133
+ if (!authState) {
134
+ throw new Error("Invalid or expired OIDC state");
135
+ }
136
+
137
+ // Delete the used state now (before any further processing)
138
+ this.db.prepare("DELETE FROM oidc_auth_states WHERE state = @state").run({ state });
139
+
140
+ // 3. Clean up any expired auth states (older than 10 minutes)
141
+ this.cleanupExpiredStates();
142
+
143
+ // 4. Get the provider config and exchange the code
144
+ const provider = this.providerService.get(providerId);
145
+ if (!provider) {
146
+ throw new Error(`OIDC provider not found: ${providerId}`);
147
+ }
148
+
149
+ const config = await discovery(
150
+ new URL(provider.issuerUrl),
151
+ provider.clientId,
152
+ { client_secret: provider.clientSecret },
153
+ );
154
+
155
+ // Build the callback URL with code + state params (as the provider would redirect to)
156
+ const callbackUrl = new URL(`${this.baseUrl}/api/oidc/callback/${provider.id}`);
157
+ callbackUrl.searchParams.set("code", code);
158
+ callbackUrl.searchParams.set("state", state);
159
+
160
+ // Exchange the authorization code for tokens.
161
+ // openid-client automatically:
162
+ // - Validates the state parameter
163
+ // - Validates the ID token (signature, iss, aud, exp, expectedNonce)
164
+ // - Fetches the JWKS from the provider
165
+ const tokenSet = await authorizationCodeGrant(
166
+ config,
167
+ callbackUrl,
168
+ {
169
+ expectedNonce: authState.nonce,
170
+ pkceCodeVerifier: authState.code_verifier,
171
+ expectedState: state,
172
+ },
173
+ );
174
+
175
+ // 5. Extract claims from the ID token
176
+ const idTokenClaims = tokenSet.claims();
177
+ if (!idTokenClaims) {
178
+ throw new Error("OIDC provider did not return an ID token");
179
+ }
180
+
181
+ // Cast claims to access OIDC standard claims (preferred_username, name, etc.)
182
+ const claims = idTokenClaims as Record<string, unknown>;
183
+
184
+ this.logger.info("OIDC callback successful", {
185
+ providerId,
186
+ sub: String(claims.sub ?? ""),
187
+ email: claims.email as string | undefined,
188
+ });
189
+
190
+ // 6. Resolve the role from the provider's role_attribute, if configured
191
+ let role: "admin" | "user" = provider.defaultRole;
192
+ if (provider.roleAttribute) {
193
+ const resolvedRole = this.resolveRoleFromClaim(claims, provider.roleAttribute, provider.defaultRole);
194
+ if (resolvedRole) role = resolvedRole;
195
+ }
196
+
197
+ // 7. Find or create the user
198
+ const { user, created } = this.userService.findOrCreateFromOidc(
199
+ {
200
+ sub: String(claims.sub ?? ""),
201
+ preferred_username: claims.preferred_username as string | undefined,
202
+ name: claims.name as string | undefined,
203
+ picture: claims.picture as string | undefined,
204
+ email: claims.email as string | undefined,
205
+ },
206
+ providerId,
207
+ role,
208
+ );
209
+
210
+ return { user, providerId, created, redirectTo: authState.redirect_to ?? "/browse" };
211
+ }
212
+
213
+ /**
214
+ * Resolve a Starr role from an OIDC claim using a JSONPath-like expression.
215
+ *
216
+ * Simple dot-notation path (e.g. "$.starr_roles" or "starr_roles"):
217
+ * - If the resolved value is an array, it's checked for "admin".
218
+ * - If it's a string, it's checked as-is.
219
+ * - If it's a boolean, true=admin, false=defaultRole.
220
+ * - If not found, returns null (caller uses defaultRole).
221
+ */
222
+ private resolveRoleFromClaim(
223
+ claims: Record<string, unknown>,
224
+ roleAttribute: string,
225
+ defaultRole: "admin" | "user",
226
+ ): "admin" | "user" | null {
227
+ try {
228
+ // Simple dot-notation path resolution
229
+ const path = roleAttribute.replace(/^\$\.?/, ""); // strip leading "$." or "$"
230
+ const keys = path.split(".");
231
+ let value: unknown = claims;
232
+ for (const key of keys) {
233
+ if (value && typeof value === "object" && key in (value as Record<string, unknown>)) {
234
+ value = (value as Record<string, unknown>)[key];
235
+ } else {
236
+ return null; // path not found
237
+ }
238
+ }
239
+
240
+ // Interpret the value
241
+ if (Array.isArray(value)) {
242
+ return value.includes("admin") ? "admin" : defaultRole;
243
+ }
244
+ if (typeof value === "string") {
245
+ return value === "admin" ? "admin" : (value === "user" ? "user" : null);
246
+ }
247
+ if (typeof value === "boolean") {
248
+ return value ? "admin" : defaultRole;
249
+ }
250
+ } catch {
251
+ // If anything goes wrong resolving the role, fall back to default
252
+ }
253
+ return null;
254
+ }
255
+
256
+ /** Delete auth states older than 10 minutes (capped at 100 rows). */
257
+ private cleanupExpiredStates(): void {
258
+ // Use SQLite's datetime arithmetic for consistent comparison with
259
+ // the stored datetime('now') format (no T, no Z).
260
+ this.db.prepare(`
261
+ DELETE FROM oidc_auth_states
262
+ WHERE created_at < datetime('now', @offset)
263
+ LIMIT 100
264
+ `).run({ offset: `-${STATE_TTL_MS / 1000} seconds` });
265
+ }
266
+ }
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import type Database from "better-sqlite3";
6
+ import type { Logger } from "../../types/plugin.js";
7
+
8
+ /**
9
+ * Shape of an OIDC provider as stored.
10
+ * client_secret is encrypted at rest.
11
+ */
12
+ export interface OidcProvider {
13
+ id: string;
14
+ name: string;
15
+ issuerUrl: string;
16
+ clientId: string;
17
+ clientSecret: string; // plaintext in-memory, encrypted at rest
18
+ scopes: string;
19
+ roleAttribute: string | null;
20
+ defaultRole: "admin" | "user";
21
+ enabled: boolean;
22
+ createdAt: string;
23
+ updatedAt: string;
24
+ }
25
+
26
+ /**
27
+ * Public-facing shape (no secret, for the login page).
28
+ */
29
+ export interface OidcProviderPublic {
30
+ id: string;
31
+ name: string;
32
+ enabled: boolean;
33
+ }
34
+
35
+ /** Shape used when creating or updating a provider. */
36
+ export interface OidcProviderInput {
37
+ name: string;
38
+ issuerUrl: string;
39
+ clientId: string;
40
+ clientSecret: string;
41
+ scopes?: string;
42
+ roleAttribute?: string | null;
43
+ defaultRole?: "admin" | "user";
44
+ enabled?: boolean;
45
+ }
46
+
47
+ export class OidcProviderService {
48
+ constructor(
49
+ private db: Database.Database,
50
+ private encrypt: (plaintext: string) => string,
51
+ private decrypt: (ciphertext: string) => string,
52
+ private logger: Logger,
53
+ ) {}
54
+
55
+ /** List all providers (with encrypted secrets, for admin API). */
56
+ list(): OidcProvider[] {
57
+ const rows = this.db
58
+ .prepare("SELECT * FROM oidc_providers ORDER BY name ASC")
59
+ .all() as any[];
60
+ return rows.map(this.rowToProvider);
61
+ }
62
+
63
+ /** List only enabled providers (public info, no secrets). */
64
+ listEnabled(): OidcProviderPublic[] {
65
+ const rows = this.db
66
+ .prepare("SELECT id, name, enabled FROM oidc_providers WHERE enabled = 1 ORDER BY name ASC")
67
+ .all() as any[];
68
+ return rows.map((r: any) => ({
69
+ id: r.id,
70
+ name: r.name,
71
+ enabled: !!r.enabled,
72
+ }));
73
+ }
74
+
75
+ /** Get a single provider by ID. */
76
+ get(id: string): OidcProvider | null {
77
+ const row = this.db
78
+ .prepare("SELECT * FROM oidc_providers WHERE id = @id")
79
+ .get({ id }) as any;
80
+ return row ? this.rowToProvider(row) : null;
81
+ }
82
+
83
+ /** Create a new provider. Returns the created provider. */
84
+ create(input: OidcProviderInput): OidcProvider {
85
+ const id = randomUUID();
86
+ const encryptedSecret = this.encrypt(input.clientSecret);
87
+ const scopes = input.scopes ?? "openid profile email";
88
+ const defaultRole = input.defaultRole ?? "user";
89
+
90
+ this.db.prepare(`
91
+ INSERT INTO oidc_providers (id, name, issuer_url, client_id, client_secret,
92
+ scopes, role_attribute, default_role, enabled)
93
+ VALUES (@id, @name, @issuerUrl, @clientId, @clientSecret,
94
+ @scopes, @roleAttribute, @defaultRole, @enabled)
95
+ `).run({
96
+ id,
97
+ name: input.name,
98
+ issuerUrl: input.issuerUrl,
99
+ clientId: input.clientId,
100
+ clientSecret: encryptedSecret,
101
+ scopes,
102
+ roleAttribute: input.roleAttribute ?? null,
103
+ defaultRole,
104
+ enabled: input.enabled !== false ? 1 : 0,
105
+ });
106
+
107
+ this.logger.info("OIDC provider created", { id, name: input.name });
108
+ return this.get(id)!;
109
+ }
110
+
111
+ /** Update an existing provider. Fields that are undefined are not changed. */
112
+ update(id: string, input: Partial<OidcProviderInput>): OidcProvider | null {
113
+ const existing = this.get(id);
114
+ if (!existing) return null;
115
+
116
+ const name = input.name ?? existing.name;
117
+ const issuerUrl = input.issuerUrl ?? existing.issuerUrl;
118
+ const clientId = input.clientId ?? existing.clientId;
119
+ const clientSecret = input.clientSecret
120
+ ? this.encrypt(input.clientSecret)
121
+ : existing.clientSecret; // Keep existing encrypted value if no new secret provided
122
+ const scopes = input.scopes ?? existing.scopes;
123
+ const roleAttribute = input.roleAttribute !== undefined
124
+ ? (input.roleAttribute ?? null)
125
+ : existing.roleAttribute;
126
+ const defaultRole = input.defaultRole ?? existing.defaultRole;
127
+ const enabled = input.enabled !== undefined ? (input.enabled ? 1 : 0) : (existing.enabled ? 1 : 0);
128
+
129
+ this.db.prepare(`
130
+ UPDATE oidc_providers SET
131
+ name = @name,
132
+ issuer_url = @issuerUrl,
133
+ client_id = @clientId,
134
+ client_secret = @clientSecret,
135
+ scopes = @scopes,
136
+ role_attribute = @roleAttribute,
137
+ default_role = @defaultRole,
138
+ enabled = @enabled,
139
+ updated_at = datetime('now')
140
+ WHERE id = @id
141
+ `).run({ id, name, issuerUrl, clientId, clientSecret, scopes, roleAttribute, defaultRole, enabled });
142
+
143
+ this.logger.info("OIDC provider updated", { id, name });
144
+ return this.get(id);
145
+ }
146
+
147
+ /** Delete a provider. Also cleans up auth states referencing it (CASCADE). */
148
+ delete(id: string): boolean {
149
+ const existing = this.get(id);
150
+ if (!existing) return false;
151
+
152
+ this.db.prepare("DELETE FROM oidc_providers WHERE id = @id").run({ id });
153
+ this.logger.info("OIDC provider deleted", { id, name: existing.name });
154
+ return true;
155
+ }
156
+
157
+ // ── Private helpers ──
158
+
159
+ private rowToProvider(row: any): OidcProvider {
160
+ return {
161
+ id: row.id,
162
+ name: row.name,
163
+ issuerUrl: row.issuer_url,
164
+ clientId: row.client_id,
165
+ clientSecret: this.decrypt(row.client_secret),
166
+ scopes: row.scopes,
167
+ roleAttribute: row.role_attribute,
168
+ defaultRole: row.default_role,
169
+ enabled: !!row.enabled,
170
+ createdAt: row.created_at,
171
+ updatedAt: row.updated_at,
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { randomUUID } from "node:crypto";
5
+ import type Database from "better-sqlite3";
6
+ import type { Logger } from "../../types/plugin.js";
7
+
8
+ export interface User {
9
+ id: string;
10
+ username: string;
11
+ displayName: string | null;
12
+ avatarUrl: string | null;
13
+ role: "admin" | "user";
14
+ provider: "local" | "oidc";
15
+ oidcSub: string | null;
16
+ oidcProviderId: string | null;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ }
20
+
21
+ /** Shape for updating user role (admin-only). */
22
+ export interface UserRoleUpdate {
23
+ role: "admin" | "user";
24
+ }
25
+
26
+ export class UserService {
27
+ constructor(
28
+ private db: Database.Database,
29
+ private logger: Logger,
30
+ ) {}
31
+
32
+ /** Get a user by UUID or the fixed admin id. */
33
+ get(id: string): User | null {
34
+ const row = this.db
35
+ .prepare("SELECT * FROM users WHERE id = @id")
36
+ .get({ id }) as any;
37
+ return row ? this.rowToUser(row) : null;
38
+ }
39
+
40
+ /** Get a user by username. */
41
+ getByUsername(username: string): User | null {
42
+ const row = this.db
43
+ .prepare("SELECT * FROM users WHERE username = @username")
44
+ .get({ username }) as any;
45
+ return row ? this.rowToUser(row) : null;
46
+ }
47
+
48
+ /**
49
+ * Find a user by OIDC subject and provider.
50
+ * This is the primary lookup during OIDC callback.
51
+ */
52
+ getByOidcSub(oidcSub: string, oidcProviderId: string): User | null {
53
+ const row = this.db
54
+ .prepare(
55
+ "SELECT * FROM users WHERE oidc_sub = @sub AND oidc_provider_id = @providerId"
56
+ )
57
+ .get({ sub: oidcSub, providerId: oidcProviderId }) as any;
58
+ return row ? this.rowToUser(row) : null;
59
+ }
60
+
61
+ /**
62
+ * Find or create a user from OIDC claims during callback.
63
+ *
64
+ * Steps:
65
+ * 1. Look up by (oidc_sub, oidc_provider_id).
66
+ * 2. If found, update display_name, avatar_url, username from claims.
67
+ * 3. If not found, create a new user with provider='oidc'.
68
+ *
69
+ * Returns the user and a boolean indicating whether it was newly created.
70
+ */
71
+ findOrCreateFromOidc(
72
+ claims: {
73
+ sub: string;
74
+ preferred_username?: string;
75
+ name?: string;
76
+ picture?: string;
77
+ email?: string;
78
+ },
79
+ oidcProviderId: string,
80
+ defaultRole: "admin" | "user",
81
+ ): { user: User; created: boolean } {
82
+ // 1. Look up existing user by OIDC identity
83
+ let user = this.getByOidcSub(claims.sub, oidcProviderId);
84
+ if (user) {
85
+ // 2. Update claims (username/display name/avatar may have changed at the IdP)
86
+ const displayName = claims.name ?? claims.preferred_username ?? claims.sub;
87
+ const avatarUrl = claims.picture ?? null;
88
+ const username = claims.preferred_username ?? claims.sub;
89
+
90
+ this.db.prepare(`
91
+ UPDATE users SET
92
+ username = @username,
93
+ display_name = @displayName,
94
+ avatar_url = @avatarUrl,
95
+ updated_at = datetime('now')
96
+ WHERE id = @id
97
+ `).run({
98
+ id: user.id,
99
+ username,
100
+ displayName,
101
+ avatarUrl,
102
+ });
103
+
104
+ user = this.get(user.id)!;
105
+ return { user, created: false };
106
+ }
107
+
108
+ // 3. Create new user
109
+ const id = randomUUID();
110
+ const displayName = claims.name ?? claims.preferred_username ?? claims.sub;
111
+ const avatarUrl = claims.picture ?? null;
112
+ const username = claims.preferred_username ?? claims.sub;
113
+
114
+ // If the preferred_username is already taken, append a suffix
115
+ let finalUsername = username;
116
+ let suffix = 1;
117
+ while (this.getByUsername(finalUsername)) {
118
+ finalUsername = `${username}_${suffix}`;
119
+ suffix++;
120
+ }
121
+
122
+ this.db.prepare(`
123
+ INSERT INTO users (id, username, display_name, avatar_url, role, provider,
124
+ oidc_sub, oidc_provider_id)
125
+ VALUES (@id, @username, @displayName, @avatarUrl, @role, 'oidc',
126
+ @oidcSub, @oidcProviderId)
127
+ `).run({
128
+ id,
129
+ username: finalUsername,
130
+ displayName,
131
+ avatarUrl,
132
+ role: defaultRole,
133
+ oidcSub: claims.sub,
134
+ oidcProviderId,
135
+ });
136
+
137
+ user = this.get(id)!;
138
+ this.logger.info("OIDC user auto-created", {
139
+ userId: user.id,
140
+ username: user.username,
141
+ providerId: oidcProviderId,
142
+ });
143
+ return { user, created: true };
144
+ }
145
+
146
+ /** List all users (admin only). */
147
+ list(): User[] {
148
+ const rows = this.db
149
+ .prepare("SELECT * FROM users ORDER BY created_at ASC")
150
+ .all() as any[];
151
+ return rows.map(this.rowToUser);
152
+ }
153
+
154
+ /** Count how many admin users exist. */
155
+ countAdmins(): number {
156
+ const row = this.db
157
+ .prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'")
158
+ .get() as { count: number };
159
+ return row.count;
160
+ }
161
+
162
+ /** Change a user's role. Returns the updated user or null if not found. */
163
+ setRole(id: string, role: "admin" | "user"): User | null {
164
+ const user = this.get(id);
165
+ if (!user) return null;
166
+
167
+ // Prevent demoting the built-in local admin account
168
+ if (id === "admin" && role === "user") {
169
+ throw new Error("Cannot demote the built-in admin account");
170
+ }
171
+
172
+ // Prevent demoting the last admin
173
+ if (user.role === "admin" && role === "user") {
174
+ const adminCount = this.countAdmins();
175
+ if (adminCount <= 1) {
176
+ throw new Error("Cannot demote the last admin user");
177
+ }
178
+ }
179
+
180
+ this.db.prepare(`
181
+ UPDATE users SET role = @role, updated_at = datetime('now') WHERE id = @id
182
+ `).run({ id, role });
183
+
184
+ this.logger.info("user role changed", {
185
+ targetUserId: id,
186
+ newRole: role,
187
+ });
188
+ return this.get(id);
189
+ }
190
+
191
+ /** Check if there is at least one admin user. */
192
+ hasAdmin(): boolean {
193
+ const row = this.db
194
+ .prepare("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1")
195
+ .get();
196
+ return row !== undefined;
197
+ }
198
+
199
+ /**
200
+ * Resolve a user identifier (UUID or "admin") to a user.
201
+ * Used when validating sessions that may have either format in user_id.
202
+ */
203
+ resolveUser(userId: string): User | null {
204
+ // Try direct lookup by id first
205
+ const user = this.get(userId);
206
+ if (user) return user;
207
+ // Fallback: try as username (for backward compat with old sessions)
208
+ return this.getByUsername(userId);
209
+ }
210
+
211
+ // ── Private helpers ──
212
+
213
+ private rowToUser(row: any): User {
214
+ return {
215
+ id: row.id,
216
+ username: row.username,
217
+ displayName: row.display_name,
218
+ avatarUrl: row.avatar_url,
219
+ role: row.role,
220
+ provider: row.provider,
221
+ oidcSub: row.oidc_sub,
222
+ oidcProviderId: row.oidc_provider_id,
223
+ createdAt: row.created_at,
224
+ updatedAt: row.updated_at,
225
+ };
226
+ }
227
+ }