@frigear-nu/oauth-server 0.1.2 → 0.1.3

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 (41) 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/composables/useOAuthApplicationActions.d.ts +3 -0
  5. package/dist/runtime/app/composables/useOAuthApplicationActions.js +29 -13
  6. package/dist/runtime/app/composables/useOAuthApplications.d.ts +15 -14
  7. package/dist/runtime/app/composables/useOAuthApplications.js +25 -14
  8. package/dist/runtime/app/pages/admin.vue +113 -62
  9. package/dist/runtime/app/pages/applications.vue +51 -34
  10. package/dist/runtime/app/pages/authorize.vue +3 -2
  11. package/dist/runtime/i18n/lang/da.json +6 -1
  12. package/dist/runtime/i18n/lang/en.json +6 -1
  13. package/dist/runtime/server/middleware/oauth-cors.js +6 -5
  14. package/dist/runtime/server/routes/api/admin/application-revoke.post.js +5 -3
  15. package/dist/runtime/server/routes/api/admin/application-secret.post.js +3 -1
  16. package/dist/runtime/server/routes/api/admin/application.delete.js +4 -3
  17. package/dist/runtime/server/routes/api/admin/application.get.js +6 -3
  18. package/dist/runtime/server/routes/api/admin/application.patch.js +4 -3
  19. package/dist/runtime/server/routes/api/admin/applications.get.js +20 -8
  20. package/dist/runtime/server/routes/api/admin/applications.post.js +3 -3
  21. package/dist/runtime/server/routes/api/consent.get.js +4 -3
  22. package/dist/runtime/server/routes/oidc/authorize.get.js +5 -6
  23. package/dist/runtime/server/routes/oidc/authorize.post.js +1 -2
  24. package/dist/runtime/server/routes/oidc/openid-configuration.get.js +6 -5
  25. package/dist/runtime/server/routes/oidc/revoke.post.js +15 -3
  26. package/dist/runtime/server/routes/oidc/token.post.js +20 -5
  27. package/dist/runtime/server/utils/oauth-access.d.ts +2 -0
  28. package/dist/runtime/server/utils/oauth-access.js +7 -1
  29. package/dist/runtime/server/utils/oauth-applications.d.ts +7 -2
  30. package/dist/runtime/server/utils/oauth-applications.js +39 -12
  31. package/dist/runtime/server/utils/oauth-authorize.d.ts +8 -4
  32. package/dist/runtime/server/utils/oauth-authorize.js +10 -8
  33. package/dist/runtime/server/utils/oauth-config.d.ts +5 -0
  34. package/dist/runtime/server/utils/oauth-config.js +5 -0
  35. package/dist/runtime/server/utils/oauth-keys.js +3 -2
  36. package/dist/runtime/utils/api.d.ts +28 -0
  37. package/dist/runtime/utils/api.js +27 -0
  38. package/dist/runtime/utils/index.d.ts +1 -0
  39. package/dist/runtime/utils/index.js +1 -0
  40. package/dist/types.d.mts +1 -1
  41. package/package.json +14 -9
@@ -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,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
  }
@@ -14,11 +14,15 @@ export declare const authorizeRequestSchema: z.ZodObject<{
14
14
  }, z.core.$strip>;
15
15
  export type AuthorizeRequest = z.infer<typeof authorizeRequestSchema>;
16
16
  /**
17
- * Parses and validates an authorization request: known, active client, an allowed redirect URI, PKCE
18
- * (S256 is mandatory) and the scopes. Failures are plain HTTP errors because the redirect URI cannot
19
- * be trusted to carry them yet.
17
+ * Parses the authorization request parameters (a query or a body). Failures are a
18
+ * plain 400 `bad_request` naming the bad parameters, because the redirect URI cannot be trusted to carry them yet.
20
19
  */
21
- export declare function validateAuthorizeRequest(input: unknown): Promise<{
20
+ export declare function parseAuthorizeRequest(input: unknown): AuthorizeRequest;
21
+ /**
22
+ * Validates a parsed authorization request: known, active client, an allowed redirect URI and the scopes (PKCE with
23
+ * S256 is enforced by the schema). Failures are plain HTTP errors because the redirect URI cannot be trusted yet.
24
+ */
25
+ export declare function validateAuthorizeRequest(request: AuthorizeRequest): Promise<{
22
26
  request: AuthorizeRequest;
23
27
  application: OAuthApplication;
24
28
  scope: string[];
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { badRequest, forbidden } from "@frigear-nu/module-kit/server";
2
3
  import { resolveScope, validateRedirectUri } from "../../utils/index.js";
3
4
  export const authorizeRequestSchema = z.object({
4
5
  client_id: z.string().min(1),
@@ -10,22 +11,23 @@ export const authorizeRequestSchema = z.object({
10
11
  scope: z.string().max(1024).optional(),
11
12
  nonce: z.string().max(512).optional()
12
13
  });
13
- const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
14
- export async function validateAuthorizeRequest(input) {
14
+ export function parseAuthorizeRequest(input) {
15
15
  const parsed = authorizeRequestSchema.safeParse(input);
16
- if (!parsed.success) throw fail(400, `Invalid authorization request: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
17
- const request = parsed.data;
16
+ if (!parsed.success) throw badRequest(`Invalid authorization request: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
17
+ return parsed.data;
18
+ }
19
+ export async function validateAuthorizeRequest(request) {
18
20
  const application = await getApplication(request.client_id);
19
- if (!application || !application.isActive) throw fail(400, "Unknown or disabled application.");
21
+ if (!application || !application.isActive) throw badRequest("Unknown or disabled application.");
20
22
  const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath, allowNativeRedirects: application.clientType === "public" });
21
- if (!valid) throw fail(400, "The redirect_uri is not allowed for this application.");
23
+ if (!valid) throw badRequest("The redirect_uri is not allowed for this application.");
22
24
  const scope = resolveScope(request.scope, getSupportedScopes());
23
- if (scope.length === 0) throw fail(400, "None of the requested scopes is supported.");
25
+ if (scope.length === 0) throw badRequest("None of the requested scopes is supported.");
24
26
  return { request, application, scope };
25
27
  }
26
28
  export async function assertApplicationAccess(event, user, application) {
27
29
  const access = await checkApplicationAccess(user, application, event);
28
- if (!access.allowed) throw fail(403, access.reason ?? "You do not have access to this application.");
30
+ if (!access.allowed) throw forbidden(access.reason ?? "You do not have access to this application.");
29
31
  }
30
32
  export function getSignInPath() {
31
33
  const config = useRuntimeConfig();
@@ -19,3 +19,8 @@ export declare function oauthError(event: H3Event, statusCode: number, error: st
19
19
  error_description?: string | undefined;
20
20
  error: string;
21
21
  };
22
+ /**
23
+ * The `error_description` for a token or revocation request whose body failed `readValidatedBody` (h3's 400 with the
24
+ * zod issues in `data`, or unparsable JSON), so the route can still answer the RFC 6749 `invalid_request` body.
25
+ */
26
+ export declare function invalidRequestDescription(error: unknown): string;
@@ -24,3 +24,8 @@ export function oauthError(event, statusCode, error, description) {
24
24
  setResponseHeader(event, "Cache-Control", "no-store");
25
25
  return { error, ...description ? { error_description: description } : {} };
26
26
  }
27
+ export function invalidRequestDescription(error) {
28
+ const issues = error?.data?.issues;
29
+ if (!Array.isArray(issues) || !issues.length) return "The request body is malformed.";
30
+ return `Invalid request: ${issues.map((issue) => [issue.path?.map(String).join("."), issue.message].filter(Boolean).join(" ")).join(", ")}`;
31
+ }
@@ -1,8 +1,9 @@
1
1
  import { exportJWK, importPKCS8, importSPKI } from "jose";
2
+ import { apiError } from "@frigear-nu/module-kit/server";
2
3
  export function normalizePemKey(raw) {
3
4
  return raw.trim().replace(/^["']|["']$/g, "").replace(/\\n/g, "\n").trim();
4
5
  }
5
- const notConfigured = () => createError({ statusCode: 500, statusMessage: "The OAuth server signing keys are not configured." });
6
+ const notConfigured = () => apiError(500, "not_configured", "The OAuth server signing keys are not configured.");
6
7
  let cache;
7
8
  async function loadKeys() {
8
9
  const config = useOAuthConfig();
@@ -31,7 +32,7 @@ function parsePreviousKeys() {
31
32
  const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
32
33
  return Object.fromEntries(Object.entries(parsed).map(([kid, pem]) => [kid, normalizePemKey(String(pem))]));
33
34
  } catch {
34
- throw createError({ statusCode: 500, statusMessage: "The OAuth server previous public keys are not valid JSON." });
35
+ throw apiError(500, "not_configured", "The OAuth server previous public keys are not valid JSON.");
35
36
  }
36
37
  }
37
38
  let previousCache;
@@ -0,0 +1,28 @@
1
+ /** The module API: the consent data and the caller's applications under `/api/oauth-server`, staff routes under `/api/admin/oauth-server`. */
2
+ export declare const oauthServerApi: {
3
+ /** `GET`: data for the consent screen (the authorization request's parameters as the query). */
4
+ readonly consent: "/api/oauth-server/consent";
5
+ readonly me: {
6
+ /** `GET`: the applications the signed-in user may sign in to. */
7
+ readonly applications: "/api/oauth-server/me/applications";
8
+ };
9
+ readonly admin: {
10
+ /** `GET` (paginated) and `POST` (create, 201). */
11
+ readonly applications: "/api/admin/oauth-server/applications";
12
+ /** `GET`, `PATCH`, `DELETE` (204). */
13
+ readonly application: (id: string) => string;
14
+ /** `POST`: replaces the client secret and answers `{ clientSecret }` once. */
15
+ readonly rotateSecret: (id: string) => string;
16
+ /** `POST`: signs everyone out of the application; answers the application. */
17
+ readonly revoke: (id: string) => string;
18
+ };
19
+ };
20
+ /** The OAuth 2.0 / OpenID Connect endpoints, at their standard paths. */
21
+ export declare const oidc: {
22
+ readonly configuration: "/.well-known/openid-configuration";
23
+ readonly jwks: "/.well-known/jwks.json";
24
+ readonly authorize: "/oauth/authorize";
25
+ readonly token: "/oauth/token";
26
+ readonly revoke: "/oauth/revoke";
27
+ readonly userinfo: "/oauth/userinfo";
28
+ };