@frigear-nu/oauth-server 0.0.6 → 0.0.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/README.md CHANGED
@@ -39,7 +39,7 @@ export default defineNitroPlugin((nitro) => {
39
39
  })
40
40
  ```
41
41
 
42
- Full documentation with examples: `apps/docs/content/4.modules/500.oauth-server.md`.
42
+ Full documentation with examples: `apps/docs/content/4.modules/12.oauth-server.md`.
43
43
 
44
44
  ## Development
45
45
 
package/dist/module.d.mts CHANGED
@@ -20,6 +20,7 @@ type ModuleOptions = {
20
20
  /** Extra scopes advertised and accepted next to `openid profile email`. Enrich their claims with `oauth:claims`. */
21
21
  scopes?: string[];
22
22
  settings?: {
23
+ mcp?: boolean;
23
24
  pages?: {
24
25
  /** Consent screen. On by default (`/authorize`); pass `false` to provide your own page at that path. */
25
26
  authorize?: string | boolean;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/oauth-server",
3
3
  "configKey": "oauthServer",
4
- "version": "0.0.6",
4
+ "version": "0.0.8",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,13 +1,11 @@
1
- import { defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addServerImportsDir, addImports, extendPages } from '@nuxt/kit';
2
- import { registerModule, registerDatabaseTable, defineModulePage, moduleApiUrl } from '@frigear-nu/module-kit/utils';
1
+ import { defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addServerImportsDir, addImports } from '@nuxt/kit';
2
+ import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, addModulePages, addOptionalMenuSource, moduleApiUrl } from '@frigear-nu/module-kit/utils';
3
+ import { adminRoute } from '@frigear-nu/admin/utils';
4
+ import { addAuthScope, addAuthResolver } from '@frigear-nu/auth/utils';
5
+ import { addMcpDefinitions } from '@frigear-nu/mcp/utils';
3
6
 
4
7
  const MODULE_NAME = "@frigear-nu/oauth-server";
5
8
  const API_NAME = "oauthServer";
6
- function resolvePagePath(setting, defaultPath) {
7
- if (typeof setting === "string") return setting;
8
- if (setting === true) return defaultPath;
9
- return void 0;
10
- }
11
9
  const module$1 = defineNuxtModule({
12
10
  meta: { name: MODULE_NAME, configKey: "oauthServer" },
13
11
  defaults: {
@@ -22,16 +20,29 @@ const module$1 = defineNuxtModule({
22
20
  moduleDependencies: {
23
21
  "@nuxthub/core": { version: ">=0.10" },
24
22
  "@frigear-nu/module-kit": { version: ">=0.0.1" },
23
+ "@frigear-nu/auth": { version: ">=0.0.1" },
24
+ "@frigear-nu/mcp": { version: ">=0.0.1" },
25
25
  "@frigear-nu/users": { version: ">=0.0.1" },
26
+ "@frigear-nu/ui": { version: ">=0.0.1" },
27
+ // Provides the shared page components of the admin page (`AdminPage`, `AdminTable`, ...).
28
+ "@frigear-nu/admin": { version: ">=0.0.0", optional: true },
26
29
  "@frigear-nu/menu": { version: ">=0.0.1", optional: true }
27
30
  },
28
31
  async setup(options, nuxt) {
29
32
  registerModule(MODULE_NAME, nuxt);
33
+ addAuthScope(nuxt, { name: "oauth:read", description: "See which applications you can sign in to (MCP)" });
30
34
  const resolver = createResolver(import.meta.url);
35
+ addAuthResolver(nuxt, {
36
+ name: "oauth-access-token",
37
+ jwt: true,
38
+ order: 60,
39
+ resolver: resolver.resolve("./runtime/server/resolvers/access-token")
40
+ });
41
+ registerTailwindSource(nuxt, resolver.resolve("./runtime/app"));
31
42
  const settings = options.settings ?? {};
32
- const withMenus = hasNuxtModule("@frigear-nu/menu");
43
+ registerLocales(nuxt, resolver.resolve("./runtime/i18n/lang"));
33
44
  const authorizePath = settings.pages?.authorize === false ? "/authorize" : resolvePagePath(settings.pages?.authorize ?? true, "/authorize");
34
- const adminPath = resolvePagePath(settings.pages?.admin, "/admin/oauth");
45
+ const adminPath = hasNuxtModule("@frigear-nu/admin") ? resolvePagePath(settings.pages?.admin, adminRoute(nuxt, "oauth")) : void 0;
35
46
  const applicationsPath = resolvePagePath(settings.pages?.applications, "/account/applications");
36
47
  const existing = nuxt.options.runtimeConfig.oauthServer;
37
48
  nuxt.options.runtimeConfig.oauthServer = {
@@ -79,28 +90,17 @@ const module$1 = defineNuxtModule({
79
90
  api("post", "admin/applications/:id/secret", "api/admin/application-secret.post");
80
91
  api("post", "admin/applications/:id/revoke", "api/admin/application-revoke.post");
81
92
  addServerImportsDir(resolver.resolve("./runtime/server/utils"));
93
+ if (settings.mcp === true) addMcpDefinitions(nuxt, resolver.resolve("./runtime/server/mcp"));
82
94
  addImports([
83
95
  { name: "useOAuthApplications", from: resolver.resolve("./runtime/app/composables/useOAuthApplications") },
84
96
  { name: "useMyApplications", from: resolver.resolve("./runtime/app/composables/useOAuthApplications") }
85
97
  ]);
86
- if (settings.pages?.authorize !== false || adminPath || applicationsPath) {
87
- extendPages((pages) => {
88
- const existingPaths = new Set(pages.map((page) => page.path));
89
- if (settings.pages?.authorize !== false && !existingPaths.has(authorizePath)) {
90
- pages.push(defineModulePage(MODULE_NAME, { name: "oauth-authorize", path: authorizePath, file: resolver.resolve("./runtime/app/pages/authorize.vue") }));
91
- }
92
- if (adminPath && !existingPaths.has(adminPath)) {
93
- pages.push(defineModulePage(MODULE_NAME, { name: "oauth-admin", path: adminPath, file: resolver.resolve("./runtime/app/pages/admin.vue") }));
94
- }
95
- if (applicationsPath && !existingPaths.has(applicationsPath)) {
96
- pages.push(defineModulePage(MODULE_NAME, { name: "oauth-applications", path: applicationsPath, file: resolver.resolve("./runtime/app/pages/applications.vue") }));
97
- }
98
- });
99
- }
100
- if (withMenus) {
101
- const { registerMenuSource } = await import('@frigear-nu/menu/utils');
102
- registerMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "dashboard.account" });
103
- }
98
+ addModulePages(MODULE_NAME, resolver, [
99
+ { name: "oauth-authorize", path: settings.pages?.authorize !== false ? authorizePath : void 0, file: "./runtime/app/pages/authorize.vue" },
100
+ { name: "oauth-admin", path: adminPath, file: "./runtime/app/pages/admin.vue" },
101
+ { name: "oauth-applications", path: applicationsPath, file: "./runtime/app/pages/applications.vue" }
102
+ ]);
103
+ addOptionalMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "dashboard.account" });
104
104
  }
105
105
  });
106
106
 
@@ -1,5 +1,7 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthAdminPage" });
3
+ const t = useModuleT("oauthServer");
4
+ const confirmAction = useConfirmDialog();
3
5
  const { applications, status, error, create, update, remove, rotateSecret, revokeGrants } = useOAuthApplications();
4
6
  const emptyForm = () => ({
5
7
  name: "",
@@ -77,7 +79,12 @@ function submit() {
77
79
  });
78
80
  }
79
81
  const rotate = (application) => run(async () => {
80
- if (!confirm(`Replace the client secret of ${application.name}? The old secret stops working and users are signed out.`)) return;
82
+ if (!await confirmAction({
83
+ title: t("admin.rotateTitle", "Replace the client secret of {name}?", { name: application.name }),
84
+ description: t("admin.rotateDescription", "The old secret stops working and users are signed out."),
85
+ confirmText: t("admin.newSecret", "New secret"),
86
+ destructive: true
87
+ })) return;
81
88
  const clientSecret = await rotateSecret(application.id);
82
89
  secret.value = { name: application.name, clientId: application.id, clientSecret };
83
90
  });
@@ -85,146 +92,135 @@ const toggle = (application) => run(async () => {
85
92
  await update(application.id, { isActive: !application.isActive });
86
93
  });
87
94
  const signOutEveryone = (application) => run(async () => {
88
- if (confirm(`Sign everyone out of ${application.name}?`)) await revokeGrants(application.id);
95
+ if (await confirmAction({
96
+ title: t("admin.signOutTitle", "Sign everyone out of {name}?", { name: application.name }),
97
+ confirmText: t("admin.signOutEveryone", "Sign everyone out"),
98
+ destructive: true
99
+ })) await revokeGrants(application.id);
89
100
  });
90
101
  const destroy = (application) => run(async () => {
91
- if (confirm(`Delete ${application.name}? This cannot be undone.`)) await remove(application.id);
102
+ if (await confirmAction({
103
+ title: t("admin.deleteTitle", "Delete {name}?", { name: application.name }),
104
+ description: t("admin.deleteDescription", "This cannot be undone."),
105
+ confirmText: t("admin.delete", "Delete"),
106
+ destructive: true
107
+ })) await remove(application.id);
92
108
  });
109
+ const columns = [
110
+ { accessorKey: "name", header: t("admin.application", "Application") },
111
+ { id: "access", header: t("admin.access", "Access") },
112
+ { id: "grants", header: t("admin.signedIn", "Signed in") },
113
+ { id: "actions", header: "" }
114
+ ];
115
+ const rowActions = (application) => [[
116
+ { label: t("admin.edit", "Edit"), icon: "i-lucide-pencil", onSelect: () => openEdit(application) },
117
+ { label: application.isActive ? t("admin.disable", "Disable") : t("admin.enable", "Enable"), icon: application.isActive ? "i-lucide-pause" : "i-lucide-play", onSelect: () => toggle(application) },
118
+ { label: t("admin.newSecret", "New secret"), icon: "i-lucide-key-round", onSelect: () => rotate(application) },
119
+ { label: t("admin.signOutEveryone", "Sign everyone out"), icon: "i-lucide-log-out", onSelect: () => signOutEveryone(application) }
120
+ ], [
121
+ { label: t("admin.delete", "Delete"), icon: "i-lucide-trash", color: "error", onSelect: () => destroy(application) }
122
+ ]];
93
123
  const copy = (value) => navigator.clipboard?.writeText(value);
94
124
  </script>
95
125
 
96
126
  <template>
97
- <UContainer class="py-10">
98
- <div class="mb-6 flex items-center justify-between gap-4">
99
- <h1 class="text-2xl font-semibold">
100
- OAuth applications
101
- </h1>
102
- <UButton
103
- v-if="!forbidden"
104
- icon="i-lucide-plus"
105
- @click="openCreate"
106
- >
107
- New application
108
- </UButton>
109
- </div>
127
+ <AdminPage>
128
+ <AdminPageHeader :title="t('admin.title', 'OAuth applications')">
129
+ <template #actions>
130
+ <UButton
131
+ v-if="!forbidden"
132
+ icon="i-lucide-plus"
133
+ :label="t('admin.new', 'New application')"
134
+ @click="openCreate"
135
+ />
136
+ </template>
137
+ </AdminPageHeader>
110
138
 
111
139
  <UAlert
112
140
  v-if="forbidden"
113
141
  color="error"
114
142
  variant="subtle"
115
- title="You are not allowed to manage OAuth applications."
116
- />
117
- <UAlert
118
- v-else-if="failure && !formOpen"
119
- class="mb-4"
120
- color="error"
121
- variant="subtle"
122
- :title="failure"
143
+ :title="t('admin.forbidden', 'You are not allowed to manage OAuth applications.')"
123
144
  />
145
+ <template v-else>
146
+ <UAlert
147
+ v-if="failure && !formOpen"
148
+ color="error"
149
+ variant="subtle"
150
+ :title="failure"
151
+ />
124
152
 
125
- <p
126
- v-if="!forbidden && status !== 'pending' && !applications.length"
127
- class="text-muted"
128
- >
129
- No applications yet.
130
- </p>
131
-
132
- <div class="grid gap-4">
133
- <UCard
134
- v-for="application in applications"
135
- :key="application.id"
153
+ <AdminTable
154
+ :data="applications"
155
+ :columns="columns"
156
+ :loading="status === 'pending'"
136
157
  >
137
- <div class="flex flex-wrap items-start justify-between gap-4">
158
+ <template #name-cell="{ row }">
138
159
  <div class="flex min-w-0 items-start gap-3">
139
160
  <UIcon
140
- :name="application.icon || 'i-lucide-globe'"
141
- class="mt-1 size-6 shrink-0"
161
+ :name="row.original.icon || 'i-lucide-globe'"
162
+ class="mt-0.5 size-5 shrink-0"
142
163
  />
143
164
  <div class="min-w-0">
144
- <h2 class="flex items-center gap-2 font-medium">
145
- {{ application.name }}
165
+ <p class="flex items-center gap-2 font-medium">
166
+ {{ row.original.name }}
146
167
  <UBadge
147
- v-if="!application.isActive"
168
+ v-if="!row.original.isActive"
148
169
  color="neutral"
149
170
  variant="subtle"
150
- >
151
- Disabled
152
- </UBadge>
153
- </h2>
171
+ :label="t('admin.disabled', 'Disabled')"
172
+ />
173
+ </p>
154
174
  <p class="truncate text-sm text-muted">
155
- {{ application.websiteUrl }} · {{ application.activeGrants ?? 0 }} signed in
175
+ {{ row.original.websiteUrl }}
156
176
  </p>
157
177
  <p class="truncate text-xs text-muted">
158
- Callback: {{ application.callbackUrl }}
178
+ {{ t("admin.callback", "Callback") }}: {{ row.original.callbackUrl }}
159
179
  </p>
160
- <div class="mt-2 flex flex-wrap gap-1">
161
- <UBadge
162
- v-for="role in application.allowedRoles"
163
- :key="role"
164
- variant="subtle"
165
- >
166
- {{ role }}
167
- </UBadge>
168
- <UBadge
169
- v-if="!application.allowedRoles.length"
170
- color="neutral"
171
- variant="subtle"
172
- >
173
- Every signed-in user
174
- </UBadge>
175
- </div>
176
180
  </div>
177
181
  </div>
178
- <div class="flex flex-wrap gap-2">
179
- <UButton
180
- size="sm"
181
- color="neutral"
182
- variant="outline"
183
- :disabled="busy"
184
- @click="openEdit(application)"
185
- >
186
- Edit
187
- </UButton>
188
- <UButton
189
- size="sm"
190
- color="neutral"
191
- variant="outline"
192
- :disabled="busy"
193
- @click="toggle(application)"
194
- >
195
- {{ application.isActive ? "Disable" : "Enable" }}
196
- </UButton>
197
- <UButton
198
- size="sm"
199
- color="neutral"
200
- variant="outline"
201
- :disabled="busy"
202
- @click="rotate(application)"
203
- >
204
- New secret
205
- </UButton>
206
- <UButton
207
- size="sm"
182
+ </template>
183
+ <template #access-cell="{ row }">
184
+ <div class="flex flex-wrap gap-1">
185
+ <UBadge
186
+ v-for="role in row.original.allowedRoles"
187
+ :key="role"
188
+ variant="subtle"
189
+ :label="role"
190
+ />
191
+ <UBadge
192
+ v-if="!row.original.allowedRoles.length"
208
193
  color="neutral"
209
- variant="outline"
210
- :disabled="busy"
211
- @click="signOutEveryone(application)"
212
- >
213
- Sign everyone out
214
- </UButton>
215
- <UButton
216
- size="sm"
217
- color="error"
218
- variant="outline"
219
- :disabled="busy"
220
- @click="destroy(application)"
221
- >
222
- Delete
223
- </UButton>
194
+ variant="subtle"
195
+ :label="t('admin.everyone', 'Every signed-in user')"
196
+ />
224
197
  </div>
225
- </div>
226
- </UCard>
227
- </div>
198
+ </template>
199
+ <template #grants-cell="{ row }">
200
+ {{ row.original.activeGrants ?? 0 }}
201
+ </template>
202
+ <template #actions-cell="{ row }">
203
+ <div class="flex justify-end">
204
+ <UDropdownMenu :items="rowActions(row.original)">
205
+ <UButton
206
+ icon="i-lucide-ellipsis"
207
+ color="neutral"
208
+ variant="ghost"
209
+ size="sm"
210
+ :disabled="busy"
211
+ :aria-label="t('admin.actions', 'Actions')"
212
+ />
213
+ </UDropdownMenu>
214
+ </div>
215
+ </template>
216
+ <template #empty>
217
+ <AdminEmpty
218
+ icon="i-lucide-app-window"
219
+ :title="t('admin.empty', 'No applications yet.')"
220
+ />
221
+ </template>
222
+ </AdminTable>
223
+ </template>
228
224
 
229
225
  <UModal
230
226
  v-model:open="formOpen"
@@ -248,7 +244,7 @@ const copy = (value) => navigator.clipboard?.writeText(value);
248
244
  </UFormField>
249
245
  <UFormField
250
246
  label="Website URL"
251
- help="The origin of the application, without a path. The callback is the website URL plus the configured callback path."
247
+ description="The origin of the application, without a path. The callback is the website URL plus the configured callback path."
252
248
  required
253
249
  >
254
250
  <UInput
@@ -260,7 +256,7 @@ const copy = (value) => navigator.clipboard?.writeText(value);
260
256
  </UFormField>
261
257
  <UFormField
262
258
  label="Login URL"
263
- help="Where the Open button goes. Defaults to the website URL."
259
+ description="Where the Open button goes. Defaults to the website URL."
264
260
  >
265
261
  <UInput
266
262
  v-model="form.loginUrl"
@@ -269,7 +265,7 @@ const copy = (value) => navigator.clipboard?.writeText(value);
269
265
  </UFormField>
270
266
  <UFormField
271
267
  label="Preview URL pattern"
272
- help="Optional, e.g. https://*.vercel.app, for preview deployments."
268
+ description="Optional, e.g. https://*.vercel.app, for preview deployments."
273
269
  >
274
270
  <UInput
275
271
  v-model="form.previewUrlPattern"
@@ -278,7 +274,7 @@ const copy = (value) => navigator.clipboard?.writeText(value);
278
274
  </UFormField>
279
275
  <UFormField
280
276
  label="Allowed roles"
281
- help="Leave empty to allow every signed-in user."
277
+ description="Leave empty to allow every signed-in user."
282
278
  >
283
279
  <UInputTags
284
280
  v-model="form.allowedRoles"
@@ -380,5 +376,5 @@ const copy = (value) => navigator.clipboard?.writeText(value);
380
376
  </div>
381
377
  </template>
382
378
  </UModal>
383
- </UContainer>
379
+ </AdminPage>
384
380
  </template>
@@ -1,7 +1,7 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthAuthorizePage" });
3
3
  const route = useRoute();
4
- const signInPath = useRuntimeConfig().public.users?.pages?.login || "/login";
4
+ const signInPath = useAuthPages().login;
5
5
  const { data: consent, error } = await useFetch("/api/oauth-server/consent", {
6
6
  query: route.query,
7
7
  key: `oauth-server:consent:${route.fullPath}`
@@ -0,0 +1,14 @@
1
+ {
2
+ "oauthServer": {
3
+ "menu": {
4
+ "applications": {
5
+ "label": "Applikationer",
6
+ "description": "Applikationer du kan logge ind på."
7
+ },
8
+ "admin": {
9
+ "label": "OAuth-applikationer",
10
+ "description": "Administrer de applikationer, brugere kan logge ind på."
11
+ }
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "oauthServer": {
3
+ "menu": {
4
+ "applications": {
5
+ "label": "Applications",
6
+ "description": "Applications you can sign in to."
7
+ },
8
+ "admin": {
9
+ "label": "OAuth applications",
10
+ "description": "Manage the applications users can sign in to."
11
+ }
12
+ }
13
+ }
14
+ }
@@ -5,7 +5,9 @@ export default defineMenuSource(() => {
5
5
  const item = defineMenuItem({
6
6
  id: "oauth-applications",
7
7
  label: "Applications",
8
+ labelKey: "oauthServer.menu.applications.label",
8
9
  description: "Applications you can sign in to.",
10
+ descriptionKey: "oauthServer.menu.applications.description",
9
11
  icon: "i-lucide-layout-grid",
10
12
  to: config.pages.applications,
11
13
  order: 50
@@ -17,11 +19,23 @@ export default defineMenuSource(() => {
17
19
  extendMenuItem("dashboard.account", defineMenuItem({
18
20
  id: "oauth-admin",
19
21
  label: "OAuth applications",
22
+ labelKey: "oauthServer.menu.admin.label",
20
23
  description: "Manage the applications users can sign in to.",
24
+ descriptionKey: "oauthServer.menu.admin.description",
21
25
  icon: "i-lucide-key-round",
22
26
  to: config.pages.admin,
23
27
  order: 90,
24
28
  tags: ["admin"]
25
29
  }));
30
+ extendMenuItem("admin.nav", defineMenuItem({
31
+ id: "oauth-admin",
32
+ label: "OAuth applications",
33
+ labelKey: "oauthServer.menu.admin.label",
34
+ description: "Manage the applications users can sign in to.",
35
+ descriptionKey: "oauthServer.menu.admin.description",
36
+ icon: "i-lucide-key-round",
37
+ to: config.pages.admin,
38
+ order: 40
39
+ }));
26
40
  }
27
41
  });
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,6 @@
1
+ export default defineScopedMcpTool({
2
+ description: "List the applications you can sign in to with your frigear account.",
3
+ scopes: ["oauth:read"],
4
+ annotations: { readOnlyHint: true },
5
+ handler: (_args, { event, user }) => listUserApplications(user.id, event)
6
+ });
@@ -0,0 +1,12 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { AuthPrincipal } from '@frigear-nu/auth/utils';
3
+ /**
4
+ * Lets an application call the API as the user who signed in to it: resolves an OAuth access
5
+ * token (`at+jwt`) into a principal carrying the application (`client_id`) and the granted scopes.
6
+ *
7
+ * The token's audience is the application, so it is deliberately not matched against this server:
8
+ * the application is the one holding the token. Everything else is re-checked on every request
9
+ * (the application is still active, the user still exists and may still use it), so disabling an
10
+ * application or removing a role takes effect immediately instead of when the token expires.
11
+ */
12
+ export default function resolveAccessToken(event: H3Event, token: string): Promise<AuthPrincipal | null>;
@@ -0,0 +1,14 @@
1
+ import { parseScopeString } from "@frigear-nu/auth/utils";
2
+ export default async function resolveAccessToken(event, token) {
3
+ const payload = await verifyAccessToken(event, token);
4
+ if (!payload) return null;
5
+ const [application, oauthUser] = await Promise.all([
6
+ getApplication(payload.client_id),
7
+ getOAuthUser(Number(payload.sub))
8
+ ]);
9
+ if (!application || !oauthUser) return null;
10
+ if (!(await checkApplicationAccess(oauthUser, application, event)).allowed) return null;
11
+ const user = await loadAuthUser(oauthUser.id);
12
+ if (!user) return null;
13
+ return { kind: "oauth", user, scopes: parseScopeString(payload.scope), client: { id: application.id } };
14
+ }
@@ -1,17 +1,11 @@
1
1
  import type { H3Event } from 'h3';
2
2
  import type { OAuthApplication, OAuthUser, TokenType } from '../../../types.js';
3
- /**
4
- * Gate for the admin routes. This repo has no shared permission system, so the host decides through
5
- * the `oauth:authorize` Nitro hook. Denied by default:
6
- *
7
- * nitroApp.hooks.hook('oauth:authorize', (ctx) => { ctx.allowed = isStaff(ctx.userId) })
8
- */
9
3
  export declare function requireOAuthAdmin(event: H3Event): Promise<{
10
4
  userId: number;
11
5
  }>;
12
6
  /** The user's roles as reported by the `oauth:user:roles` hook listeners (none without a listener). */
13
7
  export declare function resolveUserRoles(userId: number, event?: H3Event): Promise<string[]>;
14
- /** The user row without the password hash; includes columns the host added to `users`. */
8
+ /** The stored user without the password hash; includes columns the host added to `users`. */
15
9
  export declare function getOAuthUser(userId: number): Promise<OAuthUser | null>;
16
10
  export type ApplicationAccess = {
17
11
  allowed: boolean;
@@ -1,11 +1,8 @@
1
- import { eq } from "drizzle-orm";
2
- import { db, schema } from "@nuxthub/db";
3
1
  import { matchesAllowedRoles } from "../../utils/index.js";
2
+ const adminGate = definePolicyGate("oauth:authorize");
4
3
  export async function requireOAuthAdmin(event) {
5
4
  const userId = await requireSessionUserId(event);
6
- const ctx = { event, userId, allowed: false };
7
- await useNitroApp().hooks.callHook("oauth:authorize", ctx);
8
- if (!ctx.allowed) throw createError({ statusCode: 403, statusMessage: "You are not allowed to manage OAuth applications." });
5
+ await adminGate.assert(event, userId, "You are not allowed to manage OAuth applications.");
9
6
  return { userId };
10
7
  }
11
8
  export async function resolveUserRoles(userId, event) {
@@ -14,7 +11,7 @@ export async function resolveUserRoles(userId, event) {
14
11
  return [...new Set(ctx.roles)];
15
12
  }
16
13
  export async function getOAuthUser(userId) {
17
- const [row] = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
14
+ const row = await useAuthUserStore().findById(userId);
18
15
  if (!row) return null;
19
16
  const { password: _password, ...user } = row;
20
17
  return user;
@@ -25,5 +25,5 @@ export declare function validateAuthorizeRequest(input: unknown): Promise<{
25
25
  }>;
26
26
  /** Throws 403 unless the user may use the application (role check, active, `oauth:application:access`). */
27
27
  export declare function assertApplicationAccess(event: H3Event, user: OAuthUser, application: OAuthApplication): Promise<void>;
28
- /** Where to send people who are not signed in: the configured path or the `@frigear-nu/users` login page. */
28
+ /** Where to send people who are not signed in: the configured path or the `@frigear-nu/auth` login page. */
29
29
  export declare function getSignInPath(): string;
@@ -29,5 +29,5 @@ export async function assertApplicationAccess(event, user, application) {
29
29
  }
30
30
  export function getSignInPath() {
31
31
  const config = useRuntimeConfig();
32
- return config.oauthServer.signInPath || config.public.users?.pages?.login || "/login";
32
+ return config.oauthServer.signInPath || getLoginPath();
33
33
  }
@@ -2,7 +2,10 @@ import type { H3Event } from 'h3';
2
2
  export declare function useOAuthConfig(): any;
3
3
  /** The configured issuer, or the origin the request came in on (honouring `x-forwarded-*`). */
4
4
  export declare function getOAuthIssuer(event: H3Event): string;
5
- /** Scopes this server accepts: the defaults plus whatever the host configured. */
5
+ /**
6
+ * Scopes this server accepts: the OIDC defaults, whatever the host configured, and the API scopes
7
+ * modules register with `@frigear-nu/auth` (what an access token may do against the API).
8
+ */
6
9
  export declare function getSupportedScopes(): string[];
7
10
  /** The signed-in user's numeric id (the `users` table uses integer ids). Responds 401 when nobody is signed in. */
8
11
  export declare function requireSessionUserId(event: H3Event): Promise<number>;
@@ -1,3 +1,4 @@
1
+ import { scopes as apiScopes } from "#auth/scopes";
1
2
  export function useOAuthConfig() {
2
3
  return useRuntimeConfig().oauthServer;
3
4
  }
@@ -7,10 +8,10 @@ export function getOAuthIssuer(event) {
7
8
  return issuer.replace(/\/+$/, "");
8
9
  }
9
10
  export function getSupportedScopes() {
10
- return [.../* @__PURE__ */ new Set(["openid", "profile", "email", ...useOAuthConfig().scopes])];
11
+ return [.../* @__PURE__ */ new Set(["openid", "profile", "email", ...useOAuthConfig().scopes, ...apiScopes.map((scope) => scope.name)])];
11
12
  }
12
13
  export async function requireSessionUserId(event) {
13
- const { user } = await requireUserSession(event);
14
+ const user = await requireUser(event, { sessionOnly: true });
14
15
  return Number(user.id);
15
16
  }
16
17
  export function oauthError(event, statusCode, error, description) {
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@frigear-nu/oauth-server",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "OAuth 2.0 / OpenID Connect server for the frigear ecosystem: register applications that users with certain roles may sign in to.",
5
- "url": "https://docs.frigear.nu/modules/oauth-server",
5
+ "url": "https://docs.frigear.dev/modules/oauth-server",
6
6
  "author": {
7
7
  "name": "Frigear",
8
8
  "email": "kontakt@frigear.nu",
9
9
  "url": "https://frigear.nu"
10
10
  },
11
+ "homepage": "https://docs.frigear.dev/modules/oauth-server",
11
12
  "repository": {
12
13
  "type": "git",
13
14
  "url": "git+https://github.com/frigear-nu/frigear-nu.git",
@@ -36,9 +37,12 @@
36
37
  "playground"
37
38
  ],
38
39
  "dependencies": {
39
- "@frigear-nu/menu": "0.0.7",
40
- "@frigear-nu/module-kit": "0.1.5",
41
- "@frigear-nu/users": "1.0.7",
40
+ "@frigear-nu/admin": "0.0.2",
41
+ "@frigear-nu/module-kit": "0.1.7",
42
+ "@frigear-nu/ui": "0.0.7",
43
+ "@frigear-nu/auth": "0.0.3",
44
+ "@frigear-nu/mcp": "0.0.3",
45
+ "@frigear-nu/users": "1.0.9",
42
46
  "@libsql/client": "^0.18.0",
43
47
  "@nuxt/kit": "^4.6.0",
44
48
  "drizzle-kit": "^0.31.11",
@@ -48,15 +52,20 @@
48
52
  "zod": "^4.6.5"
49
53
  },
50
54
  "peerDependencies": {
55
+ "@frigear-nu/menu": "0.0.9",
51
56
  "@nuxt/ui": "^4.10.0",
52
57
  "@nuxthub/core": "^0.10.8"
53
58
  },
54
59
  "peerDependenciesMeta": {
60
+ "@frigear-nu/menu": {
61
+ "optional": true
62
+ },
55
63
  "@nuxt/ui": {
56
64
  "optional": true
57
65
  }
58
66
  },
59
67
  "devDependencies": {
68
+ "@frigear-nu/menu": "0.0.9",
60
69
  "@nuxt/devtools": "^3.4.2",
61
70
  "@nuxt/eslint-config": "^1.17.0",
62
71
  "@nuxt/module-builder": "^1.0.3",