@frigear-nu/oauth-server 0.1.2 → 0.2.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.
Files changed (44) hide show
  1. package/dist/module.d.mts +8 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +24 -20
  4. package/dist/runtime/app/components/OAuthApplicationSlideover.vue +1 -0
  5. package/dist/runtime/app/components/OAuthCredentialsModal.vue +1 -0
  6. package/dist/runtime/app/composables/useOAuthApplicationActions.d.ts +3 -0
  7. package/dist/runtime/app/composables/useOAuthApplicationActions.js +30 -13
  8. package/dist/runtime/app/composables/useOAuthApplications.d.ts +34 -13
  9. package/dist/runtime/app/composables/useOAuthApplications.js +26 -14
  10. package/dist/runtime/app/pages/admin.vue +114 -62
  11. package/dist/runtime/app/pages/applications.vue +52 -34
  12. package/dist/runtime/app/pages/authorize.vue +4 -2
  13. package/dist/runtime/i18n/lang/da.json +6 -1
  14. package/dist/runtime/i18n/lang/en.json +6 -1
  15. package/dist/runtime/menus.js +1 -0
  16. package/dist/runtime/server/middleware/oauth-cors.js +6 -5
  17. package/dist/runtime/server/routes/api/admin/application-revoke.post.js +5 -3
  18. package/dist/runtime/server/routes/api/admin/application-secret.post.js +3 -1
  19. package/dist/runtime/server/routes/api/admin/application.delete.js +4 -3
  20. package/dist/runtime/server/routes/api/admin/application.get.js +6 -3
  21. package/dist/runtime/server/routes/api/admin/application.patch.js +4 -3
  22. package/dist/runtime/server/routes/api/admin/applications.get.js +20 -8
  23. package/dist/runtime/server/routes/api/admin/applications.post.js +3 -3
  24. package/dist/runtime/server/routes/api/consent.get.js +4 -3
  25. package/dist/runtime/server/routes/oidc/authorize.get.js +5 -6
  26. package/dist/runtime/server/routes/oidc/authorize.post.js +1 -2
  27. package/dist/runtime/server/routes/oidc/openid-configuration.get.js +6 -5
  28. package/dist/runtime/server/routes/oidc/revoke.post.js +15 -3
  29. package/dist/runtime/server/routes/oidc/token.post.js +20 -5
  30. package/dist/runtime/server/utils/oauth-access.d.ts +2 -0
  31. package/dist/runtime/server/utils/oauth-access.js +7 -1
  32. package/dist/runtime/server/utils/oauth-applications.d.ts +7 -2
  33. package/dist/runtime/server/utils/oauth-applications.js +39 -12
  34. package/dist/runtime/server/utils/oauth-authorize.d.ts +8 -4
  35. package/dist/runtime/server/utils/oauth-authorize.js +10 -8
  36. package/dist/runtime/server/utils/oauth-config.d.ts +5 -0
  37. package/dist/runtime/server/utils/oauth-config.js +5 -0
  38. package/dist/runtime/server/utils/oauth-keys.js +3 -2
  39. package/dist/runtime/utils/api.d.ts +28 -0
  40. package/dist/runtime/utils/api.js +27 -0
  41. package/dist/runtime/utils/index.d.ts +1 -0
  42. package/dist/runtime/utils/index.js +1 -0
  43. package/dist/types.d.mts +1 -1
  44. package/package.json +14 -9
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { useModuleT, useMyApplications } from "#imports";
2
3
  defineOptions({ name: "OAuthApplicationsPage" });
3
4
  const t = useModuleT("oauthServer");
4
5
  const { applications, loading, error, refresh } = await useMyApplications();
@@ -6,17 +7,17 @@ const { applications, loading, error, refresh } = await useMyApplications();
6
7
 
7
8
  <template>
8
9
  <div class="flex flex-col gap-4">
9
- <div
10
+ <!-- The same keys as the account menu item, so the label and the title cannot drift. -->
11
+ <PageHeading
12
+ :title="t('menu.applications.label', 'Applications')"
13
+ :description="t('menu.applications.description', 'Applications you can sign in to.')"
14
+ />
15
+
16
+ <SectionCard
10
17
  v-if="loading"
11
- class="grid gap-4 md:grid-cols-2"
18
+ loading
12
19
  aria-busy="true"
13
- >
14
- <SectionCard
15
- v-for="index in 2"
16
- :key="index"
17
- loading
18
- />
19
- </div>
20
+ />
20
21
  <UEmpty
21
22
  v-else-if="error && !applications.length"
22
23
  icon="i-lucide-circle-alert"
@@ -24,33 +25,50 @@ const { applications, loading, error, refresh } = await useMyApplications();
24
25
  :description="errorMessage(error, '')"
25
26
  :actions="[{ label: t('applications.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
26
27
  />
27
- <UEmpty
28
- v-else-if="!applications.length"
29
- icon="i-lucide-layout-grid"
30
- :title="t('applications.emptyTitle', 'No applications yet')"
31
- :description="t('applications.emptyDescription', 'There are no applications you can sign in to yet.')"
32
- />
33
- <div
28
+ <SectionCard
34
29
  v-else
35
- class="grid gap-4 md:grid-cols-2"
30
+ flush
36
31
  >
37
- <UPageCard
38
- v-for="application in applications"
39
- :key="application.id"
40
- variant="outline"
41
- :title="application.name"
42
- :description="application.description ?? application.websiteUrl"
43
- :icon="application.icon || 'i-lucide-globe'"
32
+ <UEmpty
33
+ v-if="!applications.length"
34
+ variant="naked"
35
+ icon="i-lucide-layout-grid"
36
+ :title="t('applications.emptyTitle', 'No applications yet')"
37
+ :description="t('applications.emptyDescription', 'There are no applications you can sign in to yet.')"
38
+ />
39
+ <ul
40
+ v-else
41
+ class="divide-default divide-y"
44
42
  >
45
- <UButton
46
- :to="application.loginUrl"
47
- external
48
- trailing-icon="i-lucide-arrow-up-right"
49
- size="sm"
50
- :label="t('applications.open', 'Open')"
51
- :aria-label="t('applications.openApplication', 'Open {name}', { name: application.name })"
52
- />
53
- </UPageCard>
54
- </div>
43
+ <li
44
+ v-for="application in applications"
45
+ :key="application.id"
46
+ class="flex items-center gap-3 px-4 py-3"
47
+ >
48
+ <UAvatar
49
+ :icon="application.icon || 'i-lucide-globe'"
50
+ size="md"
51
+ />
52
+ <div class="min-w-0 flex-1">
53
+ <p class="text-highlighted truncate text-sm font-medium">
54
+ {{ application.name }}
55
+ </p>
56
+ <p class="text-muted truncate text-sm">
57
+ {{ application.description ?? application.websiteUrl }}
58
+ </p>
59
+ </div>
60
+ <UButton
61
+ :to="application.loginUrl"
62
+ external
63
+ color="neutral"
64
+ variant="outline"
65
+ trailing-icon="i-lucide-arrow-up-right"
66
+ size="sm"
67
+ :label="t('applications.open', 'Open')"
68
+ :aria-label="t('applications.openApplication', 'Open {name}', { name: application.name })"
69
+ />
70
+ </li>
71
+ </ul>
72
+ </SectionCard>
55
73
  </div>
56
74
  </template>
@@ -1,10 +1,12 @@
1
1
  <script setup>
2
+ import { oauthServerApi, oidc } from "../../utils/api";
3
+ import { $fetch, navigateTo, ref, useAuthPages, useErrorToast, useFetch, useModuleT, useRoute } from "#imports";
2
4
  defineOptions({ name: "OAuthAuthorizePage" });
3
5
  const t = useModuleT("oauthServer");
4
6
  const route = useRoute();
5
7
  const signInPath = useAuthPages().login;
6
8
  const showError = useErrorToast();
7
- const { data: consent, error } = await useFetch("/api/oauth-server/consent", {
9
+ const { data: consent, error } = await useFetch(oauthServerApi.consent, {
8
10
  query: route.query,
9
11
  key: `oauth-server:consent:${route.fullPath}`
10
12
  });
@@ -23,7 +25,7 @@ const pending = ref(null);
23
25
  async function decide(approved) {
24
26
  pending.value = approved ? "approve" : "deny";
25
27
  try {
26
- const { redirectUrl } = await $fetch("/oauth/authorize", {
28
+ const { redirectUrl } = await $fetch(oidc.authorize, {
27
29
  method: "POST",
28
30
  body: { ...route.query, approved }
29
31
  });
@@ -16,6 +16,8 @@
16
16
  "new": "Tilføj applikation",
17
17
  "forbiddenTitle": "Du har ikke adgang til at administrere OAuth-applikationer",
18
18
  "forbiddenDescription": "Bed en administrator om adgang.",
19
+ "loadFailed": "OAuth-applikationer kunne ikke indlæses",
20
+ "tryAgain": "Prøv igen",
19
21
  "application": "Applikation",
20
22
  "access": "Adgang",
21
23
  "signedIn": "Logget ind",
@@ -44,7 +46,10 @@
44
46
  "enabled": "Applikationen er aktiveret",
45
47
  "disabledToast": "Applikationen er deaktiveret",
46
48
  "secretReplaced": "Klienthemmeligheden er skiftet",
47
- "signedOut": "Alle er logget ud"
49
+ "signedOut": "Alle er logget ud",
50
+ "search": "Søg på navn eller klient-ID...",
51
+ "noMatches": "Ingen applikationer matcher",
52
+ "clearSearch": "Ryd søgning"
48
53
  },
49
54
  "form": {
50
55
  "createTitle": "Tilføj applikation",
@@ -16,6 +16,8 @@
16
16
  "new": "Add application",
17
17
  "forbiddenTitle": "You are not allowed to manage OAuth applications",
18
18
  "forbiddenDescription": "Ask an administrator for access.",
19
+ "loadFailed": "OAuth applications could not be loaded",
20
+ "tryAgain": "Try again",
19
21
  "application": "Application",
20
22
  "access": "Access",
21
23
  "signedIn": "Signed in",
@@ -44,7 +46,10 @@
44
46
  "enabled": "Application enabled",
45
47
  "disabledToast": "Application disabled",
46
48
  "secretReplaced": "Client secret replaced",
47
- "signedOut": "Everyone signed out"
49
+ "signedOut": "Everyone signed out",
50
+ "search": "Search name or client ID...",
51
+ "noMatches": "No applications match",
52
+ "clearSearch": "Clear search"
48
53
  },
49
54
  "form": {
50
55
  "createTitle": "Add application",
@@ -1,4 +1,5 @@
1
1
  import { defineMenuItem, defineMenuSource, extendMenuItem } from "@frigear-nu/menu/utils";
2
+ import { useRuntimeConfig } from "#imports";
2
3
  export default defineMenuSource(() => {
3
4
  const config = useRuntimeConfig().public.oauthServer;
4
5
  if (config?.pages?.applications) {
@@ -1,9 +1,10 @@
1
+ import { oidc } from "../../utils/api.js";
1
2
  const METHODS = {
2
- "/oauth/token": "POST, OPTIONS",
3
- "/oauth/revoke": "POST, OPTIONS",
4
- "/oauth/userinfo": "GET, OPTIONS",
5
- "/.well-known/openid-configuration": "GET, OPTIONS",
6
- "/.well-known/jwks.json": "GET, OPTIONS"
3
+ [oidc.token]: "POST, OPTIONS",
4
+ [oidc.revoke]: "POST, OPTIONS",
5
+ [oidc.userinfo]: "GET, OPTIONS",
6
+ [oidc.configuration]: "GET, OPTIONS",
7
+ [oidc.jwks]: "GET, OPTIONS"
7
8
  };
8
9
  export default defineEventHandler((event) => {
9
10
  const methods = METHODS[getRequestURL(event).pathname];
@@ -1,7 +1,9 @@
1
+ import { idParams } from "@frigear-nu/module-kit/server";
2
+ import { toAdminApplicationDto } from "../../../utils/oauth-applications.js";
1
3
  export default defineEventHandler(async (event) => {
2
4
  await requireOAuthAdmin(event);
3
- const id = getRouterParam(event, "id") ?? "";
4
- if (!await getApplication(id)) throw createError({ statusCode: 404, statusMessage: "Application not found." });
5
+ const { id } = await getValidatedRouterParams(event, idParams().parse);
6
+ const application = await requireApplication(id);
5
7
  await revokeApplicationGrants(id);
6
- return { revoked: true };
8
+ return toAdminApplicationDto(application, 0);
7
9
  });
@@ -1,5 +1,7 @@
1
+ import { idParams } from "@frigear-nu/module-kit/server";
1
2
  export default defineEventHandler(async (event) => {
2
3
  await requireOAuthAdmin(event);
3
- const { secret } = await rotateApplicationSecret(getRouterParam(event, "id") ?? "");
4
+ const { id } = await getValidatedRouterParams(event, idParams().parse);
5
+ const { secret } = await rotateApplicationSecret(id);
4
6
  return { clientSecret: secret };
5
7
  });
@@ -1,6 +1,7 @@
1
+ import { idParams, noContent } from "@frigear-nu/module-kit/server";
1
2
  export default defineEventHandler(async (event) => {
2
3
  await requireOAuthAdmin(event);
3
- await deleteApplication(getRouterParam(event, "id") ?? "");
4
- setResponseStatus(event, 204);
5
- return null;
4
+ const { id } = await getValidatedRouterParams(event, idParams().parse);
5
+ await deleteApplication(id);
6
+ return noContent(event);
6
7
  });
@@ -1,6 +1,9 @@
1
+ import { idParams } from "@frigear-nu/module-kit/server";
2
+ import { toAdminApplicationDto } from "../../../utils/oauth-applications.js";
1
3
  export default defineEventHandler(async (event) => {
2
4
  await requireOAuthAdmin(event);
3
- const application = await getApplication(getRouterParam(event, "id") ?? "");
4
- if (!application) throw createError({ statusCode: 404, statusMessage: "Application not found." });
5
- return { ...application, callbackUrl: `${application.websiteUrl}${useOAuthConfig().callbackPath}` };
5
+ const { id } = await getValidatedRouterParams(event, idParams().parse);
6
+ const application = await requireApplication(id);
7
+ const grants = await countActiveGrants([id]);
8
+ return toAdminApplicationDto(application, grants[id] ?? 0);
6
9
  });
@@ -1,9 +1,10 @@
1
- import { applicationPatchSchema } from "../../../utils/oauth-applications.js";
1
+ import { idParams } from "@frigear-nu/module-kit/server";
2
+ import { applicationPatchSchema, toAdminApplicationDto } from "../../../utils/oauth-applications.js";
2
3
  export default defineEventHandler(async (event) => {
3
4
  await requireOAuthAdmin(event);
4
- const id = getRouterParam(event, "id") ?? "";
5
+ const { id } = await getValidatedRouterParams(event, idParams().parse);
5
6
  const patch = await readValidatedBody(event, applicationPatchSchema.parse);
6
7
  const application = await updateApplication(id, patch);
7
8
  if (patch.isActive === false) await revokeApplicationGrants(id);
8
- return application;
9
+ return toAdminApplicationDto(application);
9
10
  });
@@ -1,11 +1,23 @@
1
+ import { db, schema } from "@nuxthub/db";
2
+ import { count, or } from "drizzle-orm";
3
+ import { likeContains, orderByQuery, pageQuery, pageRange, paginate } from "@frigear-nu/module-kit/server";
4
+ import { toApplication, toAdminApplicationDto } from "../../../utils/oauth-applications.js";
5
+ const querySchema = pageQuery({ sort: ["createdAt", "name", "priority"] });
1
6
  export default defineEventHandler(async (event) => {
2
7
  await requireOAuthAdmin(event);
3
- const applications = await listApplications();
4
- const grants = await countActiveGrants(applications.map((application) => application.id));
5
- return applications.map((application) => ({
6
- ...application,
7
- activeGrants: grants[application.id] ?? 0,
8
- // The redirect URI applications have to register on their side.
9
- callbackUrl: `${application.websiteUrl}${useOAuthConfig().callbackPath}`
10
- }));
8
+ const query = await getValidatedQuery(event, querySchema.parse);
9
+ const table = schema.oauthApplications;
10
+ const where = query.q ? or(likeContains(table.name, query.q), likeContains(table.id, query.q)) : void 0;
11
+ const orderBy = orderByQuery({ createdAt: table.createdAt, name: table.name, priority: table.priority }, query, { tiebreaker: table.id });
12
+ const { limit, offset } = pageRange(query);
13
+ const [rows, [totals]] = await Promise.all([
14
+ db.select().from(table).where(where).orderBy(...orderBy).limit(limit).offset(offset),
15
+ db.select({ total: count() }).from(table).where(where)
16
+ ]);
17
+ const grants = await countActiveGrants(rows.map((row) => row.id));
18
+ return paginate({
19
+ total: totals?.total ?? 0,
20
+ query,
21
+ items: rows.map((row) => toAdminApplicationDto(toApplication(row), grants[row.id] ?? 0))
22
+ });
11
23
  });
@@ -1,8 +1,8 @@
1
- import { applicationInputSchema } from "../../../utils/oauth-applications.js";
1
+ import { created } from "@frigear-nu/module-kit/server";
2
+ import { applicationInputSchema, toAdminApplicationDto } from "../../../utils/oauth-applications.js";
2
3
  export default defineEventHandler(async (event) => {
3
4
  const { userId } = await requireOAuthAdmin(event);
4
5
  const input = await readValidatedBody(event, applicationInputSchema.parse);
5
6
  const { application, secret } = await createApplication(input, userId);
6
- setResponseStatus(event, 201);
7
- return { ...application, clientId: application.id, clientSecret: secret };
7
+ return created(event, { ...toAdminApplicationDto(application, 0), clientId: application.id, clientSecret: secret });
8
8
  });
@@ -1,9 +1,10 @@
1
1
  import { getQuery } from "h3";
2
+ import { parseAuthorizeRequest } from "../../utils/oauth-authorize.js";
2
3
  export default defineEventHandler(async (event) => {
3
4
  const userId = await requireSessionUserId(event);
4
- const { application, scope } = await validateAuthorizeRequest(getQuery(event));
5
- const user = await getOAuthUser(userId);
6
- if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
5
+ const query = parseAuthorizeRequest(getQuery(event));
6
+ const { application, scope } = await validateAuthorizeRequest(query);
7
+ const user = await requireOAuthUser(userId);
7
8
  await assertApplicationAccess(event, user, application);
8
9
  return {
9
10
  application: {
@@ -1,15 +1,14 @@
1
1
  import { getQuery, getRequestURL, sendRedirect } from "h3";
2
+ import { parseAuthorizeRequest } from "../../utils/oauth-authorize.js";
2
3
  export default defineEventHandler(async (event) => {
3
- const query = getQuery(event);
4
+ const query = parseAuthorizeRequest(getQuery(event));
4
5
  const { application } = await validateAuthorizeRequest(query);
6
+ const url = getRequestURL(event);
5
7
  const session = await getUserSession(event);
6
8
  if (!session.user) {
7
- const url2 = getRequestURL(event);
8
- return sendRedirect(event, `${getSignInPath()}?redirect=${encodeURIComponent(`${url2.pathname}${url2.search}`)}`, 302);
9
+ return sendRedirect(event, `${getSignInPath()}?redirect=${encodeURIComponent(`${url.pathname}${url.search}`)}`, 302);
9
10
  }
10
- const user = await getOAuthUser(Number(session.user.id));
11
- if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
11
+ const user = await requireOAuthUser(await requireSessionUserId(event));
12
12
  await assertApplicationAccess(event, user, application);
13
- const url = getRequestURL(event);
14
13
  return sendRedirect(event, `${useOAuthConfig().authorizePath}${url.search}`, 302);
15
14
  });
@@ -11,8 +11,7 @@ export default defineEventHandler(async (event) => {
11
11
  redirect.searchParams.set("error", "access_denied");
12
12
  return { redirectUrl: redirect.toString() };
13
13
  }
14
- const user = await getOAuthUser(userId);
15
- if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
14
+ const user = await requireOAuthUser(userId);
16
15
  await assertApplicationAccess(event, user, application);
17
16
  const code = await createAuthorizationCode({
18
17
  application,
@@ -1,13 +1,14 @@
1
+ import { oidc } from "../../../utils/api.js";
1
2
  export default defineEventHandler((event) => {
2
3
  const issuer = getOAuthIssuer(event);
3
4
  setResponseHeader(event, "Cache-Control", "public, max-age=3600");
4
5
  return {
5
6
  issuer,
6
- authorization_endpoint: `${issuer}/oauth/authorize`,
7
- token_endpoint: `${issuer}/oauth/token`,
8
- userinfo_endpoint: `${issuer}/oauth/userinfo`,
9
- revocation_endpoint: `${issuer}/oauth/revoke`,
10
- jwks_uri: `${issuer}/.well-known/jwks.json`,
7
+ authorization_endpoint: `${issuer}${oidc.authorize}`,
8
+ token_endpoint: `${issuer}${oidc.token}`,
9
+ userinfo_endpoint: `${issuer}${oidc.userinfo}`,
10
+ revocation_endpoint: `${issuer}${oidc.revoke}`,
11
+ jwks_uri: `${issuer}${oidc.jwks}`,
11
12
  response_types_supported: ["code"],
12
13
  response_modes_supported: ["query"],
13
14
  grant_types_supported: ["authorization_code", "refresh_token"],
@@ -1,13 +1,25 @@
1
- import { readBody } from "h3";
1
+ import { z } from "zod";
2
+ const param = z.string().max(4096).optional();
3
+ const bodySchema = z.object({
4
+ token: param,
5
+ token_type_hint: param,
6
+ client_id: param,
7
+ client_secret: param
8
+ });
2
9
  export default defineEventHandler(async (event) => {
3
- const body = await readBody(event).catch(() => void 0);
10
+ let body;
11
+ try {
12
+ body = await readValidatedBody(event, bodySchema.parse);
13
+ } catch (error) {
14
+ return oauthError(event, 400, "invalid_request", invalidRequestDescription(error));
15
+ }
4
16
  const client = await authenticateClient(event, body);
5
17
  if (!("row" in client)) {
6
18
  if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
7
19
  return oauthError(event, 401, "invalid_client", "Client authentication failed.");
8
20
  }
9
21
  const row = client.row;
10
- if (typeof body?.token === "string") await revokeRefreshToken(body.token, row.id);
22
+ if (body.token) await revokeRefreshToken(body.token, row.id);
11
23
  setResponseStatus(event, 200);
12
24
  return {};
13
25
  });
@@ -1,8 +1,23 @@
1
- import { readBody } from "h3";
1
+ import { z } from "zod";
2
+ const param = z.string().max(4096).optional();
3
+ const bodySchema = z.object({
4
+ grant_type: param,
5
+ code: param,
6
+ redirect_uri: param,
7
+ code_verifier: param,
8
+ refresh_token: param,
9
+ client_id: param,
10
+ client_secret: param
11
+ });
2
12
  export default defineEventHandler(async (event) => {
3
13
  setResponseHeader(event, "Cache-Control", "no-store");
4
14
  setResponseHeader(event, "Pragma", "no-cache");
5
- const body = await readBody(event).catch(() => void 0);
15
+ let body;
16
+ try {
17
+ body = await readValidatedBody(event, bodySchema.parse);
18
+ } catch (error) {
19
+ return oauthError(event, 400, "invalid_request", invalidRequestDescription(error));
20
+ }
6
21
  const client = await authenticateClient(event, body);
7
22
  if (!("row" in client)) {
8
23
  if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
@@ -10,12 +25,12 @@ export default defineEventHandler(async (event) => {
10
25
  }
11
26
  const row = client.row;
12
27
  const application = toApplication(row);
13
- const grantType = body?.grant_type;
28
+ const grantType = body.grant_type;
14
29
  let grant;
15
30
  let refreshToken;
16
31
  if (grantType === "authorization_code") {
17
32
  const { code, redirect_uri: redirectUri, code_verifier: codeVerifier } = body;
18
- if (typeof code !== "string" || typeof redirectUri !== "string" || typeof codeVerifier !== "string") {
33
+ if (!code || !redirectUri || !codeVerifier) {
19
34
  return oauthError(event, 400, "invalid_request", "code, redirect_uri and code_verifier are required.");
20
35
  }
21
36
  const result = await exchangeAuthorizationCode({ code, application, redirectUri, codeVerifier });
@@ -23,7 +38,7 @@ export default defineEventHandler(async (event) => {
23
38
  grant = result.grant;
24
39
  refreshToken = (await createRefreshToken({ applicationId: application.id, ...grant })).token;
25
40
  } else if (grantType === "refresh_token") {
26
- if (typeof body?.refresh_token !== "string") return oauthError(event, 400, "invalid_request", "refresh_token is required.");
41
+ if (!body.refresh_token) return oauthError(event, 400, "invalid_request", "refresh_token is required.");
27
42
  const result = await rotateRefreshToken({ token: body.refresh_token, application });
28
43
  if ("error" in result) return oauthError(event, 400, "invalid_grant", result.error);
29
44
  grant = result.grant;
@@ -3,6 +3,8 @@ import type { OAuthApplication, OAuthUser, TokenType } from '../../../types.js';
3
3
  export declare function requireOAuthAdmin(event: H3Event): Promise<{
4
4
  userId: number;
5
5
  }>;
6
+ /** The signed-in user as stored, or 401 `unauthorized` when the account no longer exists. */
7
+ export declare function requireOAuthUser(userId: number): Promise<OAuthUser>;
6
8
  /** The user's roles as reported by the `oauth:user:roles` hook listeners (none without a listener). */
7
9
  export declare function resolveUserRoles(userId: number, event?: H3Event): Promise<string[]>;
8
10
  /** The stored user without the password hash; includes columns the host added to `users`. */
@@ -1,10 +1,16 @@
1
+ import { apiError, forbidden } from "@frigear-nu/module-kit/server";
1
2
  import { matchesAllowedRoles } from "../../utils/index.js";
2
3
  const adminGate = definePolicyGate("oauth:authorize");
3
4
  export async function requireOAuthAdmin(event) {
4
5
  const userId = await requireSessionUserId(event);
5
- await adminGate.assert(event, userId, "You are not allowed to manage OAuth applications.");
6
+ if (!await adminGate.isAllowed(event, userId)) throw forbidden("You are not allowed to manage OAuth applications.");
6
7
  return { userId };
7
8
  }
9
+ export async function requireOAuthUser(userId) {
10
+ const user = await getOAuthUser(userId);
11
+ if (!user) throw apiError(401, "unauthorized", "Unknown user.");
12
+ return user;
13
+ }
8
14
  export async function resolveUserRoles(userId, event) {
9
15
  const ctx = { event, userId, roles: [] };
10
16
  await useNitroApp().hooks.callHook("oauth:user:roles", ctx);
@@ -1,7 +1,7 @@
1
1
  import { schema } from '@nuxthub/db';
2
2
  import { z } from 'zod';
3
3
  import type { H3Event } from 'h3';
4
- import type { ApplicationInput, OAuthApplication, UserApplicationDto } from '../../../types.js';
4
+ import type { AdminApplicationDto, ApplicationInput, OAuthApplication, UserApplicationDto } from '../../../types.js';
5
5
  type ApplicationRow = typeof schema.oauthApplications.$inferSelect;
6
6
  /** Validation shared by the admin API and `createApplication` / `updateApplication`. */
7
7
  export declare const applicationInputSchema: z.ZodObject<{
@@ -39,10 +39,15 @@ export declare const applicationPatchSchema: z.ZodObject<{
39
39
  isActive: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
40
40
  }, z.core.$strip>;
41
41
  export declare function toApplication(row: ApplicationRow): OAuthApplication;
42
+ /** The redirect URI an application has to accept on its side: its website plus the configured callback path. */
43
+ export declare function applicationCallbackUrl(application: Pick<OAuthApplication, 'websiteUrl'>): string;
44
+ /** The admin view of an application (never the secret hash), with the active sign-ins when counted. */
45
+ export declare function toAdminApplicationDto(application: OAuthApplication, activeGrants?: number): AdminApplicationDto;
46
+ /** One application for a staff route, or 404 `not_found`. */
47
+ export declare function requireApplication(id: string): Promise<OAuthApplication>;
42
48
  /** The row including `secretHash`; only for credential checks. Use `getApplication` everywhere else. */
43
49
  export declare function getApplicationRow(id: string): Promise<ApplicationRow | null>;
44
50
  export declare function getApplication(id: string): Promise<OAuthApplication | null>;
45
- export declare function listApplications(): Promise<OAuthApplication[]>;
46
51
  /**
47
52
  * Creates an application. The returned `secret` is the only time the plain secret is available; it is `null` for
48
53
  * `public` clients, which have none.
@@ -1,8 +1,9 @@
1
1
  import { and, desc, eq, inArray } from "drizzle-orm";
2
2
  import { db, schema } from "@nuxthub/db";
3
3
  import { z } from "zod";
4
+ import { badRequest, isoDate, notFound } from "@frigear-nu/module-kit/server";
4
5
  import { CLIENT_TYPES, isNativeRedirectUri, isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
5
- const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
6
+ const applicationNotFound = () => notFound("Application not found.");
6
7
  const httpsUrl = z.string().trim().max(2048).refine((value) => {
7
8
  try {
8
9
  return isSecureOrigin(new URL(value));
@@ -38,13 +39,43 @@ export function toApplication(row) {
38
39
  const { secretHash: _secretHash, ...application } = row;
39
40
  return application;
40
41
  }
42
+ export function applicationCallbackUrl(application) {
43
+ return `${application.websiteUrl}${useOAuthConfig().callbackPath}`;
44
+ }
45
+ export function toAdminApplicationDto(application, activeGrants) {
46
+ return {
47
+ id: application.id,
48
+ clientType: application.clientType,
49
+ name: application.name,
50
+ description: application.description,
51
+ icon: application.icon,
52
+ priority: application.priority,
53
+ websiteUrl: application.websiteUrl,
54
+ loginUrl: application.loginUrl,
55
+ previewUrlPattern: application.previewUrlPattern,
56
+ redirectUris: application.redirectUris,
57
+ allowedRoles: application.allowedRoles,
58
+ tags: application.tags,
59
+ ownerId: application.ownerId,
60
+ isActive: application.isActive,
61
+ createdAt: isoDate(application.createdAt),
62
+ updatedAt: isoDate(application.updatedAt),
63
+ ...activeGrants === void 0 ? {} : { activeGrants },
64
+ callbackUrl: applicationCallbackUrl(application)
65
+ };
66
+ }
67
+ export async function requireApplication(id) {
68
+ const application = await getApplication(id);
69
+ if (!application) throw applicationNotFound();
70
+ return application;
71
+ }
41
72
  function normalizeInput(input) {
42
73
  const next = { ...input };
43
74
  if (next.websiteUrl !== void 0) {
44
75
  try {
45
76
  next.websiteUrl = normalizeWebsiteUrl(next.websiteUrl);
46
77
  } catch (error) {
47
- throw fail(400, error.message);
78
+ throw badRequest(error.message);
48
79
  }
49
80
  }
50
81
  if (next.redirectUris) next.redirectUris = [...new Set(next.redirectUris)];
@@ -54,7 +85,7 @@ function normalizeInput(input) {
54
85
  }
55
86
  function assertRedirectUris(uris, clientType) {
56
87
  if (clientType === "public" || !uris) return;
57
- if (uris.some((uri) => !isSecureOrigin(new URL(uri)))) throw fail(400, "Only public clients may use custom-scheme redirect URIs.");
88
+ if (uris.some((uri) => !isSecureOrigin(new URL(uri)))) throw badRequest("Only public clients may use custom-scheme redirect URIs.");
58
89
  }
59
90
  export async function getApplicationRow(id) {
60
91
  const [row] = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).limit(1);
@@ -64,10 +95,6 @@ export async function getApplication(id) {
64
95
  const row = await getApplicationRow(id);
65
96
  return row ? toApplication(row) : null;
66
97
  }
67
- export async function listApplications() {
68
- const rows = await db.select().from(schema.oauthApplications).orderBy(desc(schema.oauthApplications.priority), schema.oauthApplications.name);
69
- return rows.map(toApplication);
70
- }
71
98
  export async function createApplication(input, ownerId) {
72
99
  const data = normalizeInput(applicationInputSchema.parse(input));
73
100
  const clientType = data.clientType ?? "confidential";
@@ -86,8 +113,8 @@ export async function createApplication(input, ownerId) {
86
113
  export async function updateApplication(id, patch) {
87
114
  const { clientType, ...data } = normalizeInput(applicationPatchSchema.parse(patch));
88
115
  const existing = await getApplicationRow(id);
89
- if (!existing) throw fail(404, "Application not found.");
90
- if (clientType && clientType !== existing.clientType) throw fail(400, "The client type cannot be changed.");
116
+ if (!existing) throw applicationNotFound();
117
+ if (clientType && clientType !== existing.clientType) throw badRequest("The client type cannot be changed.");
91
118
  assertRedirectUris(data.redirectUris, existing.clientType);
92
119
  if (!Object.keys(data).length) return toApplication(existing);
93
120
  const [row] = await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning();
@@ -97,14 +124,14 @@ export async function deleteApplication(id) {
97
124
  await db.delete(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.applicationId, id));
98
125
  await db.delete(schema.oauthAuthorizationCodes).where(eq(schema.oauthAuthorizationCodes.applicationId, id));
99
126
  const deleted = await db.delete(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
100
- if (!deleted.length) throw fail(404, "Application not found.");
127
+ if (!deleted.length) throw applicationNotFound();
101
128
  }
102
129
  export async function rotateApplicationSecret(id) {
103
130
  const existing = await getApplicationRow(id);
104
- if (existing?.clientType === "public") throw fail(400, "Public clients have no client secret.");
131
+ if (existing?.clientType === "public") throw badRequest("Public clients have no client secret.");
105
132
  const secret = generateSecret();
106
133
  const updated = await db.update(schema.oauthApplications).set({ secretHash: await sha256Hex(secret) }).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
107
- if (!updated.length) throw fail(404, "Application not found.");
134
+ if (!updated.length) throw applicationNotFound();
108
135
  await revokeApplicationGrants(id);
109
136
  return { secret };
110
137
  }