@frigear-nu/oauth-server 0.1.1 → 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 (55) hide show
  1. package/dist/module.d.mts +27 -3
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +39 -20
  4. package/dist/runtime/app/components/OAuthApplicationSlideover.vue +41 -0
  5. package/dist/runtime/app/components/OAuthCredentialsModal.d.vue.ts +2 -1
  6. package/dist/runtime/app/components/OAuthCredentialsModal.vue +10 -3
  7. package/dist/runtime/app/components/OAuthCredentialsModal.vue.d.ts +2 -1
  8. package/dist/runtime/app/composables/useOAuthApplicationActions.d.ts +3 -0
  9. package/dist/runtime/app/composables/useOAuthApplicationActions.js +32 -15
  10. package/dist/runtime/app/composables/useOAuthApplications.d.ts +20 -20
  11. package/dist/runtime/app/composables/useOAuthApplications.js +35 -18
  12. package/dist/runtime/app/pages/admin.vue +116 -58
  13. package/dist/runtime/app/pages/applications.vue +59 -24
  14. package/dist/runtime/app/pages/authorize.vue +3 -2
  15. package/dist/runtime/i18n/lang/da.json +21 -4
  16. package/dist/runtime/i18n/lang/en.json +21 -4
  17. package/dist/runtime/server/bootstrap/my-applications.d.ts +7 -0
  18. package/dist/runtime/server/bootstrap/my-applications.js +4 -0
  19. package/dist/runtime/server/db/schema.postgresql.d.ts +17 -0
  20. package/dist/runtime/server/db/schema.postgresql.js +7 -1
  21. package/dist/runtime/server/db/schema.sqlite.d.ts +19 -0
  22. package/dist/runtime/server/db/schema.sqlite.js +7 -1
  23. package/dist/runtime/server/middleware/oauth-cors.d.ts +2 -0
  24. package/dist/runtime/server/middleware/oauth-cors.js +25 -0
  25. package/dist/runtime/server/routes/api/admin/application-revoke.post.js +5 -3
  26. package/dist/runtime/server/routes/api/admin/application-secret.post.js +3 -1
  27. package/dist/runtime/server/routes/api/admin/application.delete.js +4 -3
  28. package/dist/runtime/server/routes/api/admin/application.get.js +6 -3
  29. package/dist/runtime/server/routes/api/admin/application.patch.js +4 -3
  30. package/dist/runtime/server/routes/api/admin/applications.get.js +20 -8
  31. package/dist/runtime/server/routes/api/admin/applications.post.js +3 -3
  32. package/dist/runtime/server/routes/api/consent.get.js +4 -3
  33. package/dist/runtime/server/routes/api/me-applications.get.js +2 -4
  34. package/dist/runtime/server/routes/oidc/authorize.get.js +5 -6
  35. package/dist/runtime/server/routes/oidc/authorize.post.js +1 -2
  36. package/dist/runtime/server/routes/oidc/openid-configuration.get.js +8 -6
  37. package/dist/runtime/server/routes/oidc/revoke.post.js +19 -7
  38. package/dist/runtime/server/routes/oidc/token.post.js +24 -9
  39. package/dist/runtime/server/utils/oauth-access.d.ts +2 -0
  40. package/dist/runtime/server/utils/oauth-access.js +7 -1
  41. package/dist/runtime/server/utils/oauth-applications.d.ts +30 -4
  42. package/dist/runtime/server/utils/oauth-applications.js +77 -17
  43. package/dist/runtime/server/utils/oauth-authorize.d.ts +8 -4
  44. package/dist/runtime/server/utils/oauth-authorize.js +11 -9
  45. package/dist/runtime/server/utils/oauth-config.d.ts +7 -0
  46. package/dist/runtime/server/utils/oauth-config.js +10 -0
  47. package/dist/runtime/server/utils/oauth-crypto.d.ts +9 -2
  48. package/dist/runtime/server/utils/oauth-crypto.js +10 -4
  49. package/dist/runtime/server/utils/oauth-keys.js +3 -2
  50. package/dist/runtime/utils/api.d.ts +28 -0
  51. package/dist/runtime/utils/api.js +27 -0
  52. package/dist/runtime/utils/index.d.ts +15 -1
  53. package/dist/runtime/utils/index.js +33 -1
  54. package/dist/types.d.mts +1 -1
  55. package/package.json +16 -11
@@ -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,4 +1,2 @@
1
- export default defineEventHandler(async (event) => {
2
- const userId = await requireSessionUserId(event);
3
- return listUserApplications(userId, event);
4
- });
1
+ import myApplications from "../../bootstrap/my-applications.js";
2
+ export default defineEventHandler(myApplications);
@@ -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,20 +1,22 @@
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"],
14
15
  subject_types_supported: ["public"],
15
16
  id_token_signing_alg_values_supported: ["RS256"],
16
17
  scopes_supported: getSupportedScopes(),
17
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
18
+ // `none`: public clients (native apps, SPAs) identify with `client_id` and PKCE only.
19
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
18
20
  code_challenge_methods_supported: ["S256"],
19
21
  claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "picture", "email"]
20
22
  };
@@ -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);
4
- const credentials = readClientCredentials(event, body);
5
- const row = credentials ? await getApplicationRow(credentials.clientId) : null;
6
- if (!credentials || !row || !await verifyApplicationSecret(row, credentials.clientSecret)) {
7
- if (credentials?.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
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
+ }
16
+ const client = await authenticateClient(event, body);
17
+ if (!("row" in client)) {
18
+ if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
8
19
  return oauthError(event, 401, "invalid_client", "Client authentication failed.");
9
20
  }
10
- if (typeof body?.token === "string") await revokeRefreshToken(body.token, row.id);
21
+ const row = client.row;
22
+ if (body.token) await revokeRefreshToken(body.token, row.id);
11
23
  setResponseStatus(event, 200);
12
24
  return {};
13
25
  });
@@ -1,21 +1,36 @@
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);
6
- const credentials = readClientCredentials(event, body);
7
- const row = credentials ? await getApplicationRow(credentials.clientId) : null;
8
- if (!credentials || !row || !await verifyApplicationSecret(row, credentials.clientSecret)) {
9
- if (credentials?.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
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
+ }
21
+ const client = await authenticateClient(event, body);
22
+ if (!("row" in client)) {
23
+ if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
10
24
  return oauthError(event, 401, "invalid_client", "Client authentication failed.");
11
25
  }
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,10 +1,14 @@
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<{
8
+ clientType: z.ZodOptional<z.ZodEnum<{
9
+ confidential: "confidential";
10
+ public: "public";
11
+ }>>;
8
12
  name: z.ZodString;
9
13
  websiteUrl: z.ZodString;
10
14
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -18,6 +22,10 @@ export declare const applicationInputSchema: z.ZodObject<{
18
22
  isActive: z.ZodOptional<z.ZodBoolean>;
19
23
  }, z.core.$strip>;
20
24
  export declare const applicationPatchSchema: z.ZodObject<{
25
+ clientType: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
26
+ confidential: "confidential";
27
+ public: "public";
28
+ }>>>;
21
29
  name: z.ZodOptional<z.ZodString>;
22
30
  websiteUrl: z.ZodOptional<z.ZodString>;
23
31
  description: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
@@ -31,14 +39,22 @@ export declare const applicationPatchSchema: z.ZodObject<{
31
39
  isActive: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
32
40
  }, z.core.$strip>;
33
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>;
34
48
  /** The row including `secretHash`; only for credential checks. Use `getApplication` everywhere else. */
35
49
  export declare function getApplicationRow(id: string): Promise<ApplicationRow | null>;
36
50
  export declare function getApplication(id: string): Promise<OAuthApplication | null>;
37
- export declare function listApplications(): Promise<OAuthApplication[]>;
38
- /** Creates an application. The returned `secret` is the only time the plain secret is available. */
51
+ /**
52
+ * Creates an application. The returned `secret` is the only time the plain secret is available; it is `null` for
53
+ * `public` clients, which have none.
54
+ */
39
55
  export declare function createApplication(input: ApplicationInput, ownerId?: number | null): Promise<{
40
56
  application: OAuthApplication;
41
- secret: string;
57
+ secret: string | null;
42
58
  }>;
43
59
  export declare function updateApplication(id: string, patch: Partial<ApplicationInput>): Promise<OAuthApplication>;
44
60
  /** Deletes the application with its authorization codes and refresh tokens. */
@@ -48,6 +64,16 @@ export declare function rotateApplicationSecret(id: string): Promise<{
48
64
  secret: string;
49
65
  }>;
50
66
  export declare function verifyApplicationSecret(row: ApplicationRow, secret: string): Promise<boolean>;
67
+ /**
68
+ * Authenticates the client of a token or revocation request. Confidential clients must present their secret. Public
69
+ * clients are identified by `client_id` alone (their security is PKCE plus refresh token rotation); a secret sent to one
70
+ * is refused, as it can only mean the app was configured with the wrong kind of client.
71
+ */
72
+ export declare function authenticateClient(event: H3Event, body: Record<string, unknown> | undefined): Promise<{
73
+ row: ApplicationRow;
74
+ } | {
75
+ basic: boolean;
76
+ }>;
51
77
  /** Revokes every refresh token of an application (e.g. after a secret leak). */
52
78
  export declare function revokeApplicationGrants(applicationId: string): Promise<void>;
53
79
  /**
@@ -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 { isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
5
- const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
4
+ import { badRequest, isoDate, notFound } from "@frigear-nu/module-kit/server";
5
+ import { CLIENT_TYPES, isNativeRedirectUri, isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
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));
@@ -10,8 +11,17 @@ const httpsUrl = z.string().trim().max(2048).refine((value) => {
10
11
  return false;
11
12
  }
12
13
  }, "Must be a https URL (http is only allowed for localhost).");
14
+ const redirectUri = z.string().trim().max(2048).refine((value) => {
15
+ try {
16
+ const url = new URL(value);
17
+ return !url.hash && (isSecureOrigin(url) || isNativeRedirectUri(url));
18
+ } catch {
19
+ return false;
20
+ }
21
+ }, "Must be a https URL (http is only allowed for localhost) or, for public clients, a custom-scheme URI.");
13
22
  const names = z.array(z.string().trim().min(1).max(64)).max(50);
14
23
  export const applicationInputSchema = z.object({
24
+ clientType: z.enum(CLIENT_TYPES).optional(),
15
25
  name: z.string().trim().min(1).max(120),
16
26
  websiteUrl: z.string().trim().max(2048),
17
27
  description: z.string().trim().max(1e3).nullish(),
@@ -19,7 +29,7 @@ export const applicationInputSchema = z.object({
19
29
  priority: z.number().int().min(-1e3).max(1e3).optional(),
20
30
  loginUrl: httpsUrl.nullish(),
21
31
  previewUrlPattern: z.string().trim().max(2048).startsWith("https://").nullish(),
22
- redirectUris: z.array(httpsUrl).max(20).optional(),
32
+ redirectUris: z.array(redirectUri).max(20).optional(),
23
33
  allowedRoles: names.optional(),
24
34
  tags: names.optional(),
25
35
  isActive: z.boolean().optional()
@@ -29,19 +39,54 @@ export function toApplication(row) {
29
39
  const { secretHash: _secretHash, ...application } = row;
30
40
  return application;
31
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
+ }
32
72
  function normalizeInput(input) {
33
73
  const next = { ...input };
34
74
  if (next.websiteUrl !== void 0) {
35
75
  try {
36
76
  next.websiteUrl = normalizeWebsiteUrl(next.websiteUrl);
37
77
  } catch (error) {
38
- throw fail(400, error.message);
78
+ throw badRequest(error.message);
39
79
  }
40
80
  }
81
+ if (next.redirectUris) next.redirectUris = [...new Set(next.redirectUris)];
41
82
  if (next.allowedRoles) next.allowedRoles = [...new Set(next.allowedRoles)].sort();
42
83
  if (next.tags) next.tags = [...new Set(next.tags)];
43
84
  return next;
44
85
  }
86
+ function assertRedirectUris(uris, clientType) {
87
+ if (clientType === "public" || !uris) return;
88
+ if (uris.some((uri) => !isSecureOrigin(new URL(uri)))) throw badRequest("Only public clients may use custom-scheme redirect URIs.");
89
+ }
45
90
  export async function getApplicationRow(id) {
46
91
  const [row] = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).limit(1);
47
92
  return row ?? null;
@@ -50,43 +95,58 @@ export async function getApplication(id) {
50
95
  const row = await getApplicationRow(id);
51
96
  return row ? toApplication(row) : null;
52
97
  }
53
- export async function listApplications() {
54
- const rows = await db.select().from(schema.oauthApplications).orderBy(desc(schema.oauthApplications.priority), schema.oauthApplications.name);
55
- return rows.map(toApplication);
56
- }
57
98
  export async function createApplication(input, ownerId) {
58
99
  const data = normalizeInput(applicationInputSchema.parse(input));
59
- const secret = generateSecret();
100
+ const clientType = data.clientType ?? "confidential";
101
+ assertRedirectUris(data.redirectUris, clientType);
102
+ const secret = clientType === "public" ? null : generateSecret();
60
103
  const [row] = await db.insert(schema.oauthApplications).values({
61
104
  id: newId("app"),
62
- secretHash: await sha256Hex(secret),
63
105
  ownerId: ownerId ?? null,
64
- ...data
106
+ ...data,
107
+ clientType,
108
+ // A public client has no secret to hash; see `secretHash` in the schema.
109
+ secretHash: await sha256Hex(secret ?? generateSecret())
65
110
  }).returning();
66
111
  return { application: toApplication(row), secret };
67
112
  }
68
113
  export async function updateApplication(id, patch) {
69
- const data = normalizeInput(applicationPatchSchema.parse(patch));
70
- const [row] = Object.keys(data).length ? await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning() : [await getApplicationRow(id)];
71
- if (!row) throw fail(404, "Application not found.");
72
- return toApplication(row);
114
+ const { clientType, ...data } = normalizeInput(applicationPatchSchema.parse(patch));
115
+ const existing = await getApplicationRow(id);
116
+ if (!existing) throw applicationNotFound();
117
+ if (clientType && clientType !== existing.clientType) throw badRequest("The client type cannot be changed.");
118
+ assertRedirectUris(data.redirectUris, existing.clientType);
119
+ if (!Object.keys(data).length) return toApplication(existing);
120
+ const [row] = await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning();
121
+ return toApplication(row ?? existing);
73
122
  }
74
123
  export async function deleteApplication(id) {
75
124
  await db.delete(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.applicationId, id));
76
125
  await db.delete(schema.oauthAuthorizationCodes).where(eq(schema.oauthAuthorizationCodes.applicationId, id));
77
126
  const deleted = await db.delete(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
78
- if (!deleted.length) throw fail(404, "Application not found.");
127
+ if (!deleted.length) throw applicationNotFound();
79
128
  }
80
129
  export async function rotateApplicationSecret(id) {
130
+ const existing = await getApplicationRow(id);
131
+ if (existing?.clientType === "public") throw badRequest("Public clients have no client secret.");
81
132
  const secret = generateSecret();
82
133
  const updated = await db.update(schema.oauthApplications).set({ secretHash: await sha256Hex(secret) }).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
83
- if (!updated.length) throw fail(404, "Application not found.");
134
+ if (!updated.length) throw applicationNotFound();
84
135
  await revokeApplicationGrants(id);
85
136
  return { secret };
86
137
  }
87
138
  export async function verifyApplicationSecret(row, secret) {
88
139
  return safeEqual(await sha256Hex(secret), row.secretHash);
89
140
  }
141
+ export async function authenticateClient(event, body) {
142
+ const auth = readClientAuth(event, body);
143
+ const row = auth ? await getApplicationRow(auth.clientId) : null;
144
+ const basic = auth?.basic ?? false;
145
+ if (!auth || !row) return { basic };
146
+ if (row.clientType === "public") return auth.clientSecret ? { basic } : { row };
147
+ if (!auth.clientSecret || !await verifyApplicationSecret(row, auth.clientSecret)) return { basic };
148
+ return { row };
149
+ }
90
150
  export async function revokeApplicationGrants(applicationId) {
91
151
  await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq(schema.oauthRefreshTokens.applicationId, applicationId));
92
152
  await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId, userId: null, reason: "revoked" });
@@ -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.");
20
- const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath });
21
- if (!valid) throw fail(400, "The redirect_uri is not allowed for this application.");
21
+ if (!application || !application.isActive) throw badRequest("Unknown or disabled application.");
22
+ const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath, allowNativeRedirects: application.clientType === "public" });
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();
@@ -1,5 +1,7 @@
1
1
  import type { H3Event } from 'h3';
2
2
  export declare function useOAuthConfig(): any;
3
+ /** Origins allowed to call the token, revocation, userinfo and discovery endpoints from a browser. Never `*`. */
4
+ export declare function getOAuthCorsOrigins(): string[];
3
5
  /** The configured issuer, or the origin the request came in on (honouring `x-forwarded-*`). */
4
6
  export declare function getOAuthIssuer(event: H3Event): string;
5
7
  /**
@@ -17,3 +19,8 @@ export declare function oauthError(event: H3Event, statusCode: number, error: st
17
19
  error_description?: string | undefined;
18
20
  error: string;
19
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;
@@ -2,6 +2,11 @@ import { scopes as apiScopes } from "#auth/scopes";
2
2
  export function useOAuthConfig() {
3
3
  return useRuntimeConfig().oauthServer;
4
4
  }
5
+ export function getOAuthCorsOrigins() {
6
+ const configured = useOAuthConfig().corsOrigins;
7
+ const list = Array.isArray(configured) ? configured : String(configured ?? "").split(",");
8
+ return list.map((origin) => origin.trim()).filter((origin) => origin && origin !== "*");
9
+ }
5
10
  export function getOAuthIssuer(event) {
6
11
  const configured = useOAuthConfig().issuer;
7
12
  const issuer = configured || getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin;
@@ -19,3 +24,8 @@ export function oauthError(event, statusCode, error, description) {
19
24
  setResponseHeader(event, "Cache-Control", "no-store");
20
25
  return { error, ...description ? { error_description: description } : {} };
21
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
+ }
@@ -8,9 +8,16 @@ export declare function safeEqual(a: string, b: string): boolean;
8
8
  export declare function computeCodeChallenge(verifier: string): Promise<string>;
9
9
  export declare function verifyCodeChallenge(verifier: string, challenge: string): Promise<boolean>;
10
10
  /**
11
- * Client credentials from the `Authorization: Basic` header (id and secret are form-urlencoded and
12
- * split on the *first* colon, RFC 6749 §2.3.1) or from the request body.
11
+ * Client authentication data from the `Authorization: Basic` header (id and secret are form-urlencoded and split on
12
+ * the *first* colon, RFC 6749 §2.3.1) or from the request body. The secret is empty when none was sent, which is what
13
+ * public clients do. Whether that is acceptable depends on the application (see `authenticateClient`).
13
14
  */
15
+ export declare function readClientAuth(event: H3Event, body: Record<string, unknown> | undefined): {
16
+ clientId: string;
17
+ clientSecret: string;
18
+ basic: boolean;
19
+ } | null;
20
+ /** Client credentials that include a secret, or `null`. Confidential clients only; see `readClientAuth`. */
14
21
  export declare function readClientCredentials(event: H3Event, body: Record<string, unknown> | undefined): {
15
22
  clientId: string;
16
23
  clientSecret: string;