@frigear-nu/oauth-server 0.1.1 → 0.1.2

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 (37) hide show
  1. package/dist/module.d.mts +20 -3
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +16 -1
  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.js +3 -2
  9. package/dist/runtime/app/composables/useOAuthApplications.d.ts +5 -6
  10. package/dist/runtime/app/composables/useOAuthApplications.js +11 -5
  11. package/dist/runtime/app/pages/admin.vue +7 -0
  12. package/dist/runtime/app/pages/applications.vue +20 -2
  13. package/dist/runtime/i18n/lang/da.json +15 -3
  14. package/dist/runtime/i18n/lang/en.json +15 -3
  15. package/dist/runtime/server/bootstrap/my-applications.d.ts +7 -0
  16. package/dist/runtime/server/bootstrap/my-applications.js +4 -0
  17. package/dist/runtime/server/db/schema.postgresql.d.ts +17 -0
  18. package/dist/runtime/server/db/schema.postgresql.js +7 -1
  19. package/dist/runtime/server/db/schema.sqlite.d.ts +19 -0
  20. package/dist/runtime/server/db/schema.sqlite.js +7 -1
  21. package/dist/runtime/server/middleware/oauth-cors.d.ts +2 -0
  22. package/dist/runtime/server/middleware/oauth-cors.js +24 -0
  23. package/dist/runtime/server/routes/api/me-applications.get.js +2 -4
  24. package/dist/runtime/server/routes/oidc/openid-configuration.get.js +2 -1
  25. package/dist/runtime/server/routes/oidc/revoke.post.js +4 -4
  26. package/dist/runtime/server/routes/oidc/token.post.js +4 -4
  27. package/dist/runtime/server/utils/oauth-applications.d.ts +23 -2
  28. package/dist/runtime/server/utils/oauth-applications.js +42 -9
  29. package/dist/runtime/server/utils/oauth-authorize.js +1 -1
  30. package/dist/runtime/server/utils/oauth-config.d.ts +2 -0
  31. package/dist/runtime/server/utils/oauth-config.js +5 -0
  32. package/dist/runtime/server/utils/oauth-crypto.d.ts +9 -2
  33. package/dist/runtime/server/utils/oauth-crypto.js +10 -4
  34. package/dist/runtime/utils/index.d.ts +14 -1
  35. package/dist/runtime/utils/index.js +32 -1
  36. package/dist/types.d.mts +1 -1
  37. package/package.json +11 -11
package/dist/module.d.mts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
2
  import { H3Event } from 'h3';
3
3
 
4
+ /** `confidential` applications authenticate with a client secret; `public` ones (native apps, SPAs) rely on PKCE alone. */
5
+ type ClientType = 'confidential' | 'public';
4
6
  type ModuleOptions = {
5
7
  /**
6
8
  * The `iss` claim and the discovery issuer, e.g. `https://id.example.com`. Defaults to the origin of the
@@ -19,6 +21,14 @@ type ModuleOptions = {
19
21
  callbackPath?: string;
20
22
  /** Extra scopes advertised and accepted next to `openid profile email`. Enrich their claims with `oauth:claims`. */
21
23
  scopes?: string[];
24
+ /**
25
+ * Browser apps on another origin (for example the webview of a Capacitor app at `capacitor://localhost`) may call the
26
+ * token, revocation, userinfo and discovery endpoints. Exact origins only, never `*`. The authorization endpoint is
27
+ * never opened up: it is a top-level navigation. Cookies are not allowed cross-origin either.
28
+ */
29
+ cors?: {
30
+ origins?: string[];
31
+ };
22
32
  settings?: {
23
33
  mcp?: boolean;
24
34
  pages?: {
@@ -52,6 +62,8 @@ type OAuthServerRuntimeConfig = {
52
62
  codeTtl: number;
53
63
  callbackPath: string;
54
64
  scopes: string[];
65
+ /** Allowed CORS origins (`NUXT_OAUTH_SERVER_CORS_ORIGINS` takes a comma separated list). */
66
+ corsOrigins: string[] | string;
55
67
  authorizePath: string;
56
68
  signInPath?: string;
57
69
  };
@@ -73,6 +85,8 @@ type OAuthUser = {
73
85
  type OAuthApplication = {
74
86
  /** `app_` + random id; it is the OAuth `client_id`. */
75
87
  id: string;
88
+ /** `public` applications have no client secret; they must use PKCE and may register native redirect URIs. */
89
+ clientType: ClientType;
76
90
  name: string;
77
91
  description: string | null;
78
92
  icon: string | null;
@@ -80,7 +94,7 @@ type OAuthApplication = {
80
94
  websiteUrl: string;
81
95
  loginUrl: string | null;
82
96
  previewUrlPattern: string | null;
83
- /** Extra exact redirect URIs next to the derived `websiteUrl + callbackPath`. */
97
+ /** Extra exact redirect URIs next to the derived `websiteUrl + callbackPath`. Public clients may list native (custom scheme) URIs. */
84
98
  redirectUris: string[];
85
99
  /** Roles allowed to sign in. Empty = every signed-in user. */
86
100
  allowedRoles: string[];
@@ -95,6 +109,8 @@ type UserApplicationDto = Pick<OAuthApplication, 'id' | 'name' | 'description' |
95
109
  loginUrl: string;
96
110
  };
97
111
  type ApplicationInput = {
112
+ /** Fixed at creation. Defaults to `confidential`. */
113
+ clientType?: ClientType;
98
114
  name: string;
99
115
  websiteUrl: string;
100
116
  description?: string | null;
@@ -162,7 +178,8 @@ type AdminApplicationDto = Omit<OAuthApplication, 'createdAt' | 'updatedAt'> & {
162
178
  /** Returned once when an application is created. */
163
179
  type CreatedApplicationDto = AdminApplicationDto & {
164
180
  clientId: string;
165
- clientSecret: string;
181
+ /** `null` for public clients: they have no secret. */
182
+ clientSecret: string | null;
166
183
  };
167
184
  type ConsentDto = {
168
185
  application: Pick<OAuthApplication, 'id' | 'name' | 'description' | 'icon' | 'websiteUrl'>;
@@ -204,4 +221,4 @@ declare module 'nitropack' {
204
221
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
205
222
 
206
223
  export { _default as default };
207
- export type { AdminApplicationDto, ApplicationAccessContext, ApplicationInput, AuthorizeContext, ClaimsContext, ConsentDto, CreatedApplicationDto, ModuleOptions, OAuthApplication, OAuthServerRuntimeConfig, OAuthUser, PublicModuleOptions, TokenIssuedContext, TokenRevokedContext, TokenType, UserApplicationDto, UserRolesContext };
224
+ export type { AdminApplicationDto, ApplicationAccessContext, ApplicationInput, AuthorizeContext, ClaimsContext, ClientType, ConsentDto, CreatedApplicationDto, ModuleOptions, OAuthApplication, OAuthServerRuntimeConfig, OAuthUser, PublicModuleOptions, TokenIssuedContext, TokenRevokedContext, TokenType, UserApplicationDto, UserRolesContext };
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/oauth-server",
3
3
  "configKey": "oauthServer",
4
- "version": "0.1.1",
4
+ "version": "0.1.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addServerImportsDir, addImports } from '@nuxt/kit';
2
- import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, inlineModuleRuntime, addModulePages, addOptionalMenuSource, moduleApiUrl } from '@frigear-nu/module-kit/utils';
2
+ import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, registerDatabaseTable, registerBootstrapProvider, inlineModuleRuntime, addModulePages, addOptionalMenuSource, moduleApiUrl } from '@frigear-nu/module-kit/utils';
3
3
  import { adminRoute } from '@frigear-nu/admin/utils';
4
4
  import { addAuthScope, addAuthResolver } from '@frigear-nu/auth/utils';
5
5
  import { addMcpDefinitions } from '@frigear-nu/mcp/utils';
@@ -15,6 +15,7 @@ const module$1 = defineNuxtModule({
15
15
  codeTtl: 600,
16
16
  callbackPath: "/auth/frigear",
17
17
  scopes: [],
18
+ cors: { origins: [] },
18
19
  settings: { pages: {} }
19
20
  },
20
21
  moduleDependencies: {
@@ -44,6 +45,13 @@ const module$1 = defineNuxtModule({
44
45
  const authorizePath = settings.pages?.authorize === false ? "/authorize" : resolvePagePath(settings.pages?.authorize ?? true, "/authorize");
45
46
  const adminPath = hasNuxtModule("@frigear-nu/admin") ? resolvePagePath(settings.pages?.admin, adminRoute(nuxt, "oauth")) : void 0;
46
47
  const applicationsPath = resolvePagePath(settings.pages?.applications, "/account/applications");
48
+ const corsOrigins = (options.cors?.origins ?? []).map((origin) => origin.trim()).filter(Boolean);
49
+ for (const origin of corsOrigins) {
50
+ const url = URL.canParse(origin) ? new URL(origin) : void 0;
51
+ if (!url || origin === "*" || origin !== `${url.protocol}//${url.host}`) {
52
+ throw new Error(`[${MODULE_NAME}] cors.origins must be exact origins such as "https://app.example.com" or "capacitor://localhost", got "${origin}".`);
53
+ }
54
+ }
47
55
  const existing = nuxt.options.runtimeConfig.oauthServer;
48
56
  nuxt.options.runtimeConfig.oauthServer = {
49
57
  privateKey: "",
@@ -57,6 +65,7 @@ const module$1 = defineNuxtModule({
57
65
  codeTtl: options.codeTtl ?? 600,
58
66
  callbackPath: options.callbackPath ?? "/auth/frigear",
59
67
  scopes: options.scopes ?? [],
68
+ corsOrigins,
60
69
  authorizePath,
61
70
  signInPath: settings.signInPath
62
71
  };
@@ -71,6 +80,7 @@ const module$1 = defineNuxtModule({
71
80
  resolver,
72
81
  schema: (dialect) => `./runtime/server/db/schema.${dialect}`
73
82
  });
83
+ addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/oauth-cors") });
74
84
  const handler = (file) => resolver.resolve(`./runtime/server/routes/${file}`);
75
85
  addServerHandler({ route: "/.well-known/openid-configuration", method: "get", handler: handler("oidc/openid-configuration.get") });
76
86
  addServerHandler({ route: "/.well-known/jwks.json", method: "get", handler: handler("oidc/jwks.get") });
@@ -82,6 +92,11 @@ const module$1 = defineNuxtModule({
82
92
  const api = (method, path, file) => addServerHandler({ route: moduleApiUrl(API_NAME, path), method, handler: handler(file) });
83
93
  api("get", "consent", "api/consent.get");
84
94
  api("get", "me/applications", "api/me-applications.get");
95
+ registerBootstrapProvider(nuxt, {
96
+ scope: "account",
97
+ key: "oauth-server:mine",
98
+ handler: resolver.resolve("./runtime/server/bootstrap/my-applications")
99
+ });
85
100
  api("get", "admin/applications", "api/admin/applications.get");
86
101
  api("post", "admin/applications", "api/admin/applications.post");
87
102
  api("get", "admin/applications/:id", "api/admin/application.get");
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import * as z from "zod";
3
+ import { isNativeRedirectUri, isSecureOrigin } from "../../utils";
3
4
  defineOptions({ name: "OAuthApplicationSlideover" });
4
5
  const props = defineProps({
5
6
  application: { type: Object, required: false },
@@ -8,7 +9,14 @@ const props = defineProps({
8
9
  const emit = defineEmits(["close"]);
9
10
  const t = useModuleT("oauthServer");
10
11
  const { busy, run } = useAction();
12
+ const isAllowedRedirect = (value, clientType) => {
13
+ if (!URL.canParse(value)) return false;
14
+ const url = new URL(value);
15
+ return !url.hash && (isSecureOrigin(url) || clientType === "public" && isNativeRedirectUri(url));
16
+ };
11
17
  const schema = z.object({
18
+ clientType: z.enum(["confidential", "public"]),
19
+ redirectUris: z.array(z.string()),
12
20
  name: z.string().trim().min(1, t("form.nameRequired", "Enter a name.")),
13
21
  websiteUrl: z.url(t("form.websiteUrlInvalid", "Enter a full URL, for example https://app.example.com.")),
14
22
  loginUrl: z.string(),
@@ -20,6 +28,8 @@ const schema = z.object({
20
28
  tags: z.array(z.string())
21
29
  });
22
30
  const state = reactive({
31
+ clientType: props.application?.clientType ?? "confidential",
32
+ redirectUris: [...props.application?.redirectUris ?? []],
23
33
  name: props.application?.name ?? "",
24
34
  websiteUrl: props.application?.websiteUrl ?? "",
25
35
  loginUrl: props.application?.loginUrl ?? "",
@@ -31,9 +41,17 @@ const state = reactive({
31
41
  tags: [...props.application?.tags ?? []]
32
42
  });
33
43
  const formId = "oauth-application-form";
44
+ const clientTypeItems = computed(() => [
45
+ { label: t("form.clientTypeConfidential", "Confidential (server-side, has a client secret)"), value: "confidential" },
46
+ { label: t("form.clientTypePublic", "Public (native or single-page app, no secret)"), value: "public" }
47
+ ]);
48
+ const redirectUriError = computed(() => state.redirectUris.find((uri) => !isAllowedRedirect(uri, state.clientType)));
34
49
  async function onSubmit(event) {
35
50
  const value = event.data;
51
+ if (redirectUriError.value) return;
36
52
  const saved = await run(() => props.save({
53
+ clientType: value.clientType,
54
+ redirectUris: value.redirectUris,
37
55
  name: value.name,
38
56
  websiteUrl: value.websiteUrl,
39
57
  loginUrl: value.loginUrl || null,
@@ -64,6 +82,18 @@ async function onSubmit(event) {
64
82
  class="grid gap-4"
65
83
  @submit="onSubmit"
66
84
  >
85
+ <UFormField
86
+ name="clientType"
87
+ :label="t('form.clientType', 'Client type')"
88
+ :description="t('form.clientTypeHint', 'Cannot be changed later. Public clients sign in with PKCE only, so they never get a secret.')"
89
+ >
90
+ <USelect
91
+ v-model="state.clientType"
92
+ :items="clientTypeItems"
93
+ :disabled="!!application"
94
+ class="w-full"
95
+ />
96
+ </UFormField>
67
97
  <UFormField
68
98
  name="name"
69
99
  :label="t('form.name', 'Name')"
@@ -106,6 +136,17 @@ async function onSubmit(event) {
106
136
  class="w-full"
107
137
  />
108
138
  </UFormField>
139
+ <UFormField
140
+ name="redirectUris"
141
+ :label="t('form.redirectUris', 'Redirect URIs')"
142
+ :description="t('form.redirectUrisHint', 'Extra exact redirect URIs. Public clients may use custom-scheme URIs such as com.example.app:/callback.')"
143
+ :error="redirectUriError ? t('form.redirectUriInvalid', 'Enter a https URL or, for public clients, a custom-scheme URI.') : void 0"
144
+ >
145
+ <UInputTags
146
+ v-model="state.redirectUris"
147
+ class="w-full"
148
+ />
149
+ </UFormField>
109
150
  <UFormField
110
151
  name="allowedRoles"
111
152
  :label="t('form.allowedRoles', 'Allowed roles')"
@@ -2,7 +2,8 @@
2
2
  type __VLS_Props = {
3
3
  name: string;
4
4
  clientId: string;
5
- clientSecret: string;
5
+ /** `null` for a public client: it has no secret. */
6
+ clientSecret: string | null;
6
7
  };
7
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
9
  declare const _default: typeof __VLS_export;
@@ -3,7 +3,7 @@ defineOptions({ name: "OAuthCredentialsModal" });
3
3
  defineProps({
4
4
  name: { type: String, required: true },
5
5
  clientId: { type: String, required: true },
6
- clientSecret: { type: String, required: true }
6
+ clientSecret: { type: [String, null], required: true }
7
7
  });
8
8
  const t = useModuleT("oauthServer");
9
9
  const { copy } = useCopyToClipboard();
@@ -12,7 +12,7 @@ const { copy } = useCopyToClipboard();
12
12
  <template>
13
13
  <UModal
14
14
  :title="t('credentials.title', 'Credentials for {name}', { name })"
15
- :description="t('credentials.description', 'Copy the client secret now. It is only shown once.')"
15
+ :description="clientSecret ? t('credentials.description', 'Copy the client secret now. It is only shown once.') : t('credentials.publicDescription', 'This is a public client: it has no client secret and signs in with PKCE only.')"
16
16
  >
17
17
  <template #body>
18
18
  <DescriptionList
@@ -35,7 +35,14 @@ const { copy } = useCopyToClipboard();
35
35
  </div>
36
36
  </template>
37
37
  <template #clientSecret>
38
- <div class="flex items-center gap-2">
38
+ <span
39
+ v-if="!clientSecret"
40
+ class="text-muted"
41
+ >{{ t("credentials.noSecret", "None (public client)") }}</span>
42
+ <div
43
+ v-else
44
+ class="flex items-center gap-2"
45
+ >
39
46
  <code class="min-w-0 break-all">{{ clientSecret }}</code>
40
47
  <UButton
41
48
  size="xs"
@@ -2,7 +2,8 @@
2
2
  type __VLS_Props = {
3
3
  name: string;
4
4
  clientId: string;
5
- clientSecret: string;
5
+ /** `null` for a public client: it has no secret. */
6
+ clientSecret: string | null;
6
7
  };
7
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
8
9
  declare const _default: typeof __VLS_export;
@@ -108,9 +108,10 @@ export function useOAuthApplicationActions() {
108
108
  void toggle(application);
109
109
  }
110
110
  },
111
- { label: t("admin.newSecret", "New client secret"), icon: "i-lucide-key-round", onSelect: () => {
111
+ // Public clients have no secret to replace.
112
+ ...application.clientType === "public" ? [] : [{ label: t("admin.newSecret", "New client secret"), icon: "i-lucide-key-round", onSelect: () => {
112
113
  void rotateSecret(application);
113
- } },
114
+ } }],
114
115
  { label: t("admin.signOutEveryone", "Sign everyone out"), icon: "i-lucide-log-out", onSelect: () => {
115
116
  void signOutEveryone(application);
116
117
  } }
@@ -22,9 +22,8 @@ export declare function useOAuthApplications(): {
22
22
  rotateSecret: (id: string) => Promise<string>;
23
23
  revokeGrants: (id: string) => Promise<void>;
24
24
  };
25
- /** The applications the signed-in user may sign in to. */
26
- export declare function useMyApplications(): {
27
- applications: any;
28
- status: any;
29
- refresh: any;
30
- };
25
+ /**
26
+ * The applications the signed-in user may sign in to. Awaitable: `await useMyApplications()` in a page renders them on
27
+ * the server. `applications` is `[]` until loaded and after an error: check `loading` and `error` first.
28
+ */
29
+ export declare function useMyApplications(): any;
@@ -40,11 +40,17 @@ export function useOAuthApplications() {
40
40
  }
41
41
  export function useMyApplications() {
42
42
  const { loggedIn } = useUserSession();
43
- const { data, status, refresh } = useFetch(`${BASE}/me/applications`, {
43
+ const fetched = useCachedFetch(`${BASE}/me/applications`, {
44
44
  key: "oauth-server:mine",
45
- immediate: loggedIn.value,
46
- watch: false,
47
- default: () => []
45
+ ttl: 5 * 60 * 1e3,
46
+ watch: false
47
+ });
48
+ const { status, error, loading, refresh, clear } = fetched;
49
+ watch(loggedIn, (value) => {
50
+ if (value) refresh();
51
+ else clear();
48
52
  });
49
- return { applications: data, status, refresh };
53
+ const applications = computed(() => fetched.data.value ?? []);
54
+ const api = { applications, status, error, loading, refresh };
55
+ return Object.assign(fetched.then(() => api), api);
50
56
  }
@@ -54,6 +54,13 @@ const columns = computed(() => [
54
54
  :avatar="{ icon: row.original.icon || 'i-lucide-globe' }"
55
55
  >
56
56
  <template #badges>
57
+ <UBadge
58
+ v-if="row.original.clientType === 'public'"
59
+ color="info"
60
+ variant="subtle"
61
+ size="sm"
62
+ :label="t('admin.public', 'Public')"
63
+ />
57
64
  <UBadge
58
65
  v-if="!row.original.isActive"
59
66
  color="neutral"
@@ -1,13 +1,31 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "OAuthApplicationsPage" });
3
3
  const t = useModuleT("oauthServer");
4
- const { applications, status } = useMyApplications();
4
+ const { applications, loading, error, refresh } = await useMyApplications();
5
5
  </script>
6
6
 
7
7
  <template>
8
8
  <div class="flex flex-col gap-4">
9
+ <div
10
+ v-if="loading"
11
+ class="grid gap-4 md:grid-cols-2"
12
+ aria-busy="true"
13
+ >
14
+ <SectionCard
15
+ v-for="index in 2"
16
+ :key="index"
17
+ loading
18
+ />
19
+ </div>
20
+ <UEmpty
21
+ v-else-if="error && !applications.length"
22
+ icon="i-lucide-circle-alert"
23
+ :title="t('applications.loadFailed', 'Your applications could not be loaded')"
24
+ :description="errorMessage(error, '')"
25
+ :actions="[{ label: t('applications.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => refresh() }]"
26
+ />
9
27
  <UEmpty
10
- v-if="status !== 'pending' && !applications.length"
28
+ v-else-if="!applications.length"
11
29
  icon="i-lucide-layout-grid"
12
30
  :title="t('applications.emptyTitle', 'No applications yet')"
13
31
  :description="t('applications.emptyDescription', 'There are no applications you can sign in to yet.')"
@@ -19,6 +19,7 @@
19
19
  "application": "Applikation",
20
20
  "access": "Adgang",
21
21
  "signedIn": "Logget ind",
22
+ "public": "Offentlig",
22
23
  "disabled": "Deaktiveret",
23
24
  "callback": "Callback",
24
25
  "everyone": "Alle indloggede brugere",
@@ -66,7 +67,14 @@
66
67
  "tags": "Tags",
67
68
  "description": "Beskrivelse",
68
69
  "icon": "Ikon",
69
- "priority": "Prioritet"
70
+ "priority": "Prioritet",
71
+ "clientType": "Klienttype",
72
+ "clientTypeConfidential": "Fortrolig (serverside, har en klienthemmelighed)",
73
+ "clientTypePublic": "Offentlig (native app eller single-page-app, ingen hemmelighed)",
74
+ "clientTypeHint": "Kan ikke ændres senere. Offentlige klienter logger kun ind med PKCE og får derfor aldrig en hemmelighed.",
75
+ "redirectUris": "Redirect-URI'er",
76
+ "redirectUrisHint": "Ekstra præcise redirect-URI'er. Offentlige klienter må bruge URI'er med eget skema, fx com.example.app:/callback.",
77
+ "redirectUriInvalid": "Angiv en https-URL eller, for offentlige klienter, en URI med eget skema."
70
78
  },
71
79
  "credentials": {
72
80
  "title": "Legitimationsoplysninger for {name}",
@@ -75,13 +83,17 @@
75
83
  "clientSecret": "Klienthemmelighed",
76
84
  "copy": "Kopiér",
77
85
  "copiedClientId": "Klient-id kopieret",
78
- "copiedClientSecret": "Klienthemmelighed kopieret"
86
+ "copiedClientSecret": "Klienthemmelighed kopieret",
87
+ "publicDescription": "Dette er en offentlig klient: den har ingen klienthemmelighed og logger kun ind med PKCE.",
88
+ "noSecret": "Ingen (offentlig klient)"
79
89
  },
80
90
  "applications": {
81
91
  "open": "Åbn",
82
92
  "openApplication": "Åbn {name}",
83
93
  "emptyTitle": "Ingen applikationer endnu",
84
- "emptyDescription": "Der er endnu ingen applikationer, du kan logge ind på."
94
+ "emptyDescription": "Der er endnu ingen applikationer, du kan logge ind på.",
95
+ "loadFailed": "Dine applikationer kunne ikke indlæses",
96
+ "tryAgain": "Prøv igen"
85
97
  },
86
98
  "authorize": {
87
99
  "title": "Log ind på {name}",
@@ -19,6 +19,7 @@
19
19
  "application": "Application",
20
20
  "access": "Access",
21
21
  "signedIn": "Signed in",
22
+ "public": "Public",
22
23
  "disabled": "Disabled",
23
24
  "callback": "Callback",
24
25
  "everyone": "Every signed-in user",
@@ -66,7 +67,14 @@
66
67
  "tags": "Tags",
67
68
  "description": "Description",
68
69
  "icon": "Icon",
69
- "priority": "Priority"
70
+ "priority": "Priority",
71
+ "clientType": "Client type",
72
+ "clientTypeConfidential": "Confidential (server-side, has a client secret)",
73
+ "clientTypePublic": "Public (native or single-page app, no secret)",
74
+ "clientTypeHint": "Cannot be changed later. Public clients sign in with PKCE only, so they never get a secret.",
75
+ "redirectUris": "Redirect URIs",
76
+ "redirectUrisHint": "Extra exact redirect URIs. Public clients may use custom-scheme URIs such as com.example.app:/callback.",
77
+ "redirectUriInvalid": "Enter a https URL or, for public clients, a custom-scheme URI."
70
78
  },
71
79
  "credentials": {
72
80
  "title": "Credentials for {name}",
@@ -75,13 +83,17 @@
75
83
  "clientSecret": "Client secret",
76
84
  "copy": "Copy",
77
85
  "copiedClientId": "Client ID copied",
78
- "copiedClientSecret": "Client secret copied"
86
+ "copiedClientSecret": "Client secret copied",
87
+ "publicDescription": "This is a public client: it has no client secret and signs in with PKCE only.",
88
+ "noSecret": "None (public client)"
79
89
  },
80
90
  "applications": {
81
91
  "open": "Open",
82
92
  "openApplication": "Open {name}",
83
93
  "emptyTitle": "No applications yet",
84
- "emptyDescription": "There are no applications you can sign in to yet."
94
+ "emptyDescription": "There are no applications you can sign in to yet.",
95
+ "loadFailed": "Your applications could not be loaded",
96
+ "tryAgain": "Try again"
85
97
  },
86
98
  "authorize": {
87
99
  "title": "Sign in to {name}",
@@ -0,0 +1,7 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { UserApplicationDto } from '../../../types.js';
3
+ /**
4
+ * The applications the signed-in user may sign in to: the body of `GET /api/oauth-server/me/applications` and the
5
+ * account bootstrap's `oauth-server:mine` (the key `useMyApplications()` reads), so both always return the same thing.
6
+ */
7
+ export default function myApplications(event: H3Event): Promise<UserApplicationDto[]>;
@@ -0,0 +1,4 @@
1
+ export default async function myApplications(event) {
2
+ const userId = await requireSessionUserId(event);
3
+ return listUserApplications(userId, event);
4
+ }
@@ -20,6 +20,23 @@ export declare const oauthApplications: import("drizzle-orm/pg-core").PgTableWit
20
20
  identity: undefined;
21
21
  generated: undefined;
22
22
  }, {}, {}>;
23
+ clientType: import("drizzle-orm/pg-core").PgColumn<{
24
+ name: "clientType";
25
+ tableName: "oauth_applications";
26
+ dataType: "string";
27
+ columnType: "PgText";
28
+ data: "confidential" | "public";
29
+ driverParam: string;
30
+ notNull: true;
31
+ hasDefault: true;
32
+ isPrimaryKey: false;
33
+ isAutoincrement: false;
34
+ hasRuntimeDefault: false;
35
+ enumValues: ["confidential", "public"];
36
+ baseColumn: never;
37
+ identity: undefined;
38
+ generated: undefined;
39
+ }, {}, {}>;
23
40
  secretHash: import("drizzle-orm/pg-core").PgColumn<{
24
41
  name: "secretHash";
25
42
  tableName: "oauth_applications";
@@ -2,7 +2,13 @@ import { pgTable, text, integer, boolean, jsonb, timestamp, index } from "drizzl
2
2
  export const oauthApplications = pgTable("oauth_applications", {
3
3
  /** `app_` + random id; it is the OAuth `client_id`. */
4
4
  id: text().primaryKey(),
5
- /** SHA-256 hex of the client secret. The secret itself is only shown once. */
5
+ /** `confidential` clients authenticate with a secret; `public` ones (native apps, SPAs) rely on PKCE alone. */
6
+ clientType: text({ enum: ["confidential", "public"] }).notNull().default("confidential"),
7
+ /**
8
+ * SHA-256 hex of the client secret. The secret itself is only shown once. Public clients have none: they get the hash
9
+ * of a random value nobody ever sees, so the column stays `NOT NULL` (relaxing it would make SQLite rebuild this table,
10
+ * which can cascade-delete the refresh tokens that reference it) and `clientType` decides whether it is ever checked.
11
+ */
6
12
  secretHash: text().notNull(),
7
13
  name: text().notNull(),
8
14
  description: text(),
@@ -22,6 +22,25 @@ export declare const oauthApplications: import("drizzle-orm/sqlite-core").SQLite
22
22
  }, {}, {
23
23
  length: undefined;
24
24
  }>;
25
+ clientType: import("drizzle-orm/sqlite-core").SQLiteColumn<{
26
+ name: "clientType";
27
+ tableName: "oauth_applications";
28
+ dataType: "string";
29
+ columnType: "SQLiteText";
30
+ data: "confidential" | "public";
31
+ driverParam: string;
32
+ notNull: true;
33
+ hasDefault: true;
34
+ isPrimaryKey: false;
35
+ isAutoincrement: false;
36
+ hasRuntimeDefault: false;
37
+ enumValues: ["confidential", "public"];
38
+ baseColumn: never;
39
+ identity: undefined;
40
+ generated: undefined;
41
+ }, {}, {
42
+ length: number | undefined;
43
+ }>;
25
44
  secretHash: import("drizzle-orm/sqlite-core").SQLiteColumn<{
26
45
  name: "secretHash";
27
46
  tableName: "oauth_applications";
@@ -2,7 +2,13 @@ import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
2
2
  export const oauthApplications = sqliteTable("oauth_applications", {
3
3
  /** `app_` + random id; it is the OAuth `client_id`. */
4
4
  id: text().primaryKey(),
5
- /** SHA-256 hex of the client secret. The secret itself is only shown once. */
5
+ /** `confidential` clients authenticate with a secret; `public` ones (native apps, SPAs) rely on PKCE alone. */
6
+ clientType: text({ enum: ["confidential", "public"] }).notNull().default("confidential"),
7
+ /**
8
+ * SHA-256 hex of the client secret. The secret itself is only shown once. Public clients have none: they get the hash
9
+ * of a random value nobody ever sees, so the column stays `NOT NULL` (relaxing it would make SQLite rebuild this table,
10
+ * which can cascade-delete the refresh tokens that reference it) and `clientType` decides whether it is ever checked.
11
+ */
6
12
  secretHash: text().notNull(),
7
13
  name: text().notNull(),
8
14
  description: text(),
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,24 @@
1
+ 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"
7
+ };
8
+ export default defineEventHandler((event) => {
9
+ const methods = METHODS[getRequestURL(event).pathname];
10
+ if (!methods) return;
11
+ appendResponseHeader(event, "Vary", "Origin");
12
+ const origin = getRequestHeader(event, "origin");
13
+ if (!origin || !getOAuthCorsOrigins().includes(origin)) return;
14
+ setResponseHeaders(event, {
15
+ "Access-Control-Allow-Origin": origin,
16
+ "Access-Control-Allow-Methods": methods,
17
+ "Access-Control-Allow-Headers": "authorization, content-type",
18
+ "Access-Control-Max-Age": "600"
19
+ });
20
+ if (event.method === "OPTIONS") {
21
+ setResponseStatus(event, 204);
22
+ return "";
23
+ }
24
+ });
@@ -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);
@@ -14,7 +14,8 @@ export default defineEventHandler((event) => {
14
14
  subject_types_supported: ["public"],
15
15
  id_token_signing_alg_values_supported: ["RS256"],
16
16
  scopes_supported: getSupportedScopes(),
17
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
17
+ // `none`: public clients (native apps, SPAs) identify with `client_id` and PKCE only.
18
+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
18
19
  code_challenge_methods_supported: ["S256"],
19
20
  claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "picture", "email"]
20
21
  };
@@ -1,12 +1,12 @@
1
1
  import { readBody } from "h3";
2
2
  export default defineEventHandler(async (event) => {
3
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");
4
+ const client = await authenticateClient(event, body);
5
+ if (!("row" in client)) {
6
+ if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
8
7
  return oauthError(event, 401, "invalid_client", "Client authentication failed.");
9
8
  }
9
+ const row = client.row;
10
10
  if (typeof body?.token === "string") await revokeRefreshToken(body.token, row.id);
11
11
  setResponseStatus(event, 200);
12
12
  return {};
@@ -3,12 +3,12 @@ export default defineEventHandler(async (event) => {
3
3
  setResponseHeader(event, "Cache-Control", "no-store");
4
4
  setResponseHeader(event, "Pragma", "no-cache");
5
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");
6
+ const client = await authenticateClient(event, body);
7
+ if (!("row" in client)) {
8
+ if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
10
9
  return oauthError(event, 401, "invalid_client", "Client authentication failed.");
11
10
  }
11
+ const row = client.row;
12
12
  const application = toApplication(row);
13
13
  const grantType = body?.grant_type;
14
14
  let grant;
@@ -5,6 +5,10 @@ import type { ApplicationInput, OAuthApplication, UserApplicationDto } from '../
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>>>;
@@ -35,10 +43,13 @@ export declare function toApplication(row: ApplicationRow): OAuthApplication;
35
43
  export declare function getApplicationRow(id: string): Promise<ApplicationRow | null>;
36
44
  export declare function getApplication(id: string): Promise<OAuthApplication | null>;
37
45
  export declare function listApplications(): Promise<OAuthApplication[]>;
38
- /** Creates an application. The returned `secret` is the only time the plain secret is available. */
46
+ /**
47
+ * Creates an application. The returned `secret` is the only time the plain secret is available; it is `null` for
48
+ * `public` clients, which have none.
49
+ */
39
50
  export declare function createApplication(input: ApplicationInput, ownerId?: number | null): Promise<{
40
51
  application: OAuthApplication;
41
- secret: string;
52
+ secret: string | null;
42
53
  }>;
43
54
  export declare function updateApplication(id: string, patch: Partial<ApplicationInput>): Promise<OAuthApplication>;
44
55
  /** Deletes the application with its authorization codes and refresh tokens. */
@@ -48,6 +59,16 @@ export declare function rotateApplicationSecret(id: string): Promise<{
48
59
  secret: string;
49
60
  }>;
50
61
  export declare function verifyApplicationSecret(row: ApplicationRow, secret: string): Promise<boolean>;
62
+ /**
63
+ * Authenticates the client of a token or revocation request. Confidential clients must present their secret. Public
64
+ * clients are identified by `client_id` alone (their security is PKCE plus refresh token rotation); a secret sent to one
65
+ * is refused, as it can only mean the app was configured with the wrong kind of client.
66
+ */
67
+ export declare function authenticateClient(event: H3Event, body: Record<string, unknown> | undefined): Promise<{
68
+ row: ApplicationRow;
69
+ } | {
70
+ basic: boolean;
71
+ }>;
51
72
  /** Revokes every refresh token of an application (e.g. after a secret leak). */
52
73
  export declare function revokeApplicationGrants(applicationId: string): Promise<void>;
53
74
  /**
@@ -1,7 +1,7 @@
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";
4
+ import { CLIENT_TYPES, isNativeRedirectUri, isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
5
5
  const fail = (statusCode, statusMessage) => createError({ statusCode, statusMessage });
6
6
  const httpsUrl = z.string().trim().max(2048).refine((value) => {
7
7
  try {
@@ -10,8 +10,17 @@ const httpsUrl = z.string().trim().max(2048).refine((value) => {
10
10
  return false;
11
11
  }
12
12
  }, "Must be a https URL (http is only allowed for localhost).");
13
+ const redirectUri = z.string().trim().max(2048).refine((value) => {
14
+ try {
15
+ const url = new URL(value);
16
+ return !url.hash && (isSecureOrigin(url) || isNativeRedirectUri(url));
17
+ } catch {
18
+ return false;
19
+ }
20
+ }, "Must be a https URL (http is only allowed for localhost) or, for public clients, a custom-scheme URI.");
13
21
  const names = z.array(z.string().trim().min(1).max(64)).max(50);
14
22
  export const applicationInputSchema = z.object({
23
+ clientType: z.enum(CLIENT_TYPES).optional(),
15
24
  name: z.string().trim().min(1).max(120),
16
25
  websiteUrl: z.string().trim().max(2048),
17
26
  description: z.string().trim().max(1e3).nullish(),
@@ -19,7 +28,7 @@ export const applicationInputSchema = z.object({
19
28
  priority: z.number().int().min(-1e3).max(1e3).optional(),
20
29
  loginUrl: httpsUrl.nullish(),
21
30
  previewUrlPattern: z.string().trim().max(2048).startsWith("https://").nullish(),
22
- redirectUris: z.array(httpsUrl).max(20).optional(),
31
+ redirectUris: z.array(redirectUri).max(20).optional(),
23
32
  allowedRoles: names.optional(),
24
33
  tags: names.optional(),
25
34
  isActive: z.boolean().optional()
@@ -38,10 +47,15 @@ function normalizeInput(input) {
38
47
  throw fail(400, error.message);
39
48
  }
40
49
  }
50
+ if (next.redirectUris) next.redirectUris = [...new Set(next.redirectUris)];
41
51
  if (next.allowedRoles) next.allowedRoles = [...new Set(next.allowedRoles)].sort();
42
52
  if (next.tags) next.tags = [...new Set(next.tags)];
43
53
  return next;
44
54
  }
55
+ function assertRedirectUris(uris, clientType) {
56
+ 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.");
58
+ }
45
59
  export async function getApplicationRow(id) {
46
60
  const [row] = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).limit(1);
47
61
  return row ?? null;
@@ -56,20 +70,28 @@ export async function listApplications() {
56
70
  }
57
71
  export async function createApplication(input, ownerId) {
58
72
  const data = normalizeInput(applicationInputSchema.parse(input));
59
- const secret = generateSecret();
73
+ const clientType = data.clientType ?? "confidential";
74
+ assertRedirectUris(data.redirectUris, clientType);
75
+ const secret = clientType === "public" ? null : generateSecret();
60
76
  const [row] = await db.insert(schema.oauthApplications).values({
61
77
  id: newId("app"),
62
- secretHash: await sha256Hex(secret),
63
78
  ownerId: ownerId ?? null,
64
- ...data
79
+ ...data,
80
+ clientType,
81
+ // A public client has no secret to hash; see `secretHash` in the schema.
82
+ secretHash: await sha256Hex(secret ?? generateSecret())
65
83
  }).returning();
66
84
  return { application: toApplication(row), secret };
67
85
  }
68
86
  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);
87
+ const { clientType, ...data } = normalizeInput(applicationPatchSchema.parse(patch));
88
+ 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.");
91
+ assertRedirectUris(data.redirectUris, existing.clientType);
92
+ if (!Object.keys(data).length) return toApplication(existing);
93
+ const [row] = await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning();
94
+ return toApplication(row ?? existing);
73
95
  }
74
96
  export async function deleteApplication(id) {
75
97
  await db.delete(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.applicationId, id));
@@ -78,6 +100,8 @@ export async function deleteApplication(id) {
78
100
  if (!deleted.length) throw fail(404, "Application not found.");
79
101
  }
80
102
  export async function rotateApplicationSecret(id) {
103
+ const existing = await getApplicationRow(id);
104
+ if (existing?.clientType === "public") throw fail(400, "Public clients have no client secret.");
81
105
  const secret = generateSecret();
82
106
  const updated = await db.update(schema.oauthApplications).set({ secretHash: await sha256Hex(secret) }).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
83
107
  if (!updated.length) throw fail(404, "Application not found.");
@@ -87,6 +111,15 @@ export async function rotateApplicationSecret(id) {
87
111
  export async function verifyApplicationSecret(row, secret) {
88
112
  return safeEqual(await sha256Hex(secret), row.secretHash);
89
113
  }
114
+ export async function authenticateClient(event, body) {
115
+ const auth = readClientAuth(event, body);
116
+ const row = auth ? await getApplicationRow(auth.clientId) : null;
117
+ const basic = auth?.basic ?? false;
118
+ if (!auth || !row) return { basic };
119
+ if (row.clientType === "public") return auth.clientSecret ? { basic } : { row };
120
+ if (!auth.clientSecret || !await verifyApplicationSecret(row, auth.clientSecret)) return { basic };
121
+ return { row };
122
+ }
90
123
  export async function revokeApplicationGrants(applicationId) {
91
124
  await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq(schema.oauthRefreshTokens.applicationId, applicationId));
92
125
  await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId, userId: null, reason: "revoked" });
@@ -17,7 +17,7 @@ export async function validateAuthorizeRequest(input) {
17
17
  const request = parsed.data;
18
18
  const application = await getApplication(request.client_id);
19
19
  if (!application || !application.isActive) throw fail(400, "Unknown or disabled application.");
20
- const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath });
20
+ const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath, allowNativeRedirects: application.clientType === "public" });
21
21
  if (!valid) throw fail(400, "The redirect_uri is not allowed for this application.");
22
22
  const scope = resolveScope(request.scope, getSupportedScopes());
23
23
  if (scope.length === 0) throw fail(400, "None of the requested scopes is supported.");
@@ -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
  /**
@@ -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;
@@ -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;
@@ -26,15 +26,17 @@ export async function computeCodeChallenge(verifier) {
26
26
  export async function verifyCodeChallenge(verifier, challenge) {
27
27
  return safeEqual(await computeCodeChallenge(verifier), challenge);
28
28
  }
29
- export function readClientCredentials(event, body) {
29
+ export function readClientAuth(event, body) {
30
30
  const header = getHeader(event, "authorization");
31
31
  if (header?.toLowerCase().startsWith("basic ")) {
32
32
  try {
33
33
  const decoded = atob(header.slice(6).trim());
34
34
  const index = decoded.indexOf(":");
35
35
  if (index === -1) return null;
36
+ const clientId2 = decodeURIComponent(decoded.slice(0, index).replace(/\+/g, " "));
37
+ if (!clientId2) return null;
36
38
  return {
37
- clientId: decodeURIComponent(decoded.slice(0, index).replace(/\+/g, " ")),
39
+ clientId: clientId2,
38
40
  clientSecret: decodeURIComponent(decoded.slice(index + 1).replace(/\+/g, " ")),
39
41
  basic: true
40
42
  };
@@ -43,6 +45,10 @@ export function readClientCredentials(event, body) {
43
45
  }
44
46
  }
45
47
  const clientId = typeof body?.client_id === "string" ? body.client_id : "";
46
- const clientSecret = typeof body?.client_secret === "string" ? body.client_secret : "";
47
- return clientId && clientSecret ? { clientId, clientSecret, basic: false } : null;
48
+ if (!clientId) return null;
49
+ return { clientId, clientSecret: typeof body?.client_secret === "string" ? body.client_secret : "", basic: false };
50
+ }
51
+ export function readClientCredentials(event, body) {
52
+ const auth = readClientAuth(event, body);
53
+ return auth?.clientSecret ? auth : null;
48
54
  }
@@ -1,6 +1,8 @@
1
1
  export type * from '../../types';
2
2
  export declare const DEFAULT_SCOPES: readonly ["openid", "profile", "email"];
3
3
  export declare const DEFAULT_CALLBACK_PATH = "/auth/frigear";
4
+ /** `confidential` clients hold a secret; `public` clients (native and single-page apps) cannot and rely on PKCE alone. */
5
+ export declare const CLIENT_TYPES: readonly ["confidential", "public"];
4
6
  /** Whether a user holding `roles` may use an application restricted to `allowedRoles`. Empty = everyone. */
5
7
  export declare function matchesAllowedRoles(allowedRoles: readonly string[] | null | undefined, roles: readonly string[]): boolean;
6
8
  /** Splits a space separated scope string and drops duplicates and blanks. */
@@ -9,6 +11,12 @@ export declare function parseScope(scope: string | null | undefined): string[];
9
11
  export declare function resolveScope(requested: string | null | undefined, supported: readonly string[]): string[];
10
12
  /** HTTPS is required, except for `localhost` / loopback development hosts. */
11
13
  export declare function isSecureOrigin(url: URL): boolean;
14
+ /**
15
+ * A redirect URI that opens a native app: a private-use scheme such as `com.example.app:/callback` (RFC 8252 §7.1) or a
16
+ * webview origin such as `capacitor://localhost/callback`. No web or dangerous schemes, credentials, fragments or
17
+ * wildcards. Only meaningful for `public` clients, and always matched exactly.
18
+ */
19
+ export declare function isNativeRedirectUri(value: string | URL): boolean;
12
20
  export declare function normalizeWebsiteUrl(value: string): string;
13
21
  /** `https://*.vercel.app` -> a regex matching one host label in place of `*`. Everything else is matched literally. */
14
22
  export declare function previewPatternToRegExp(pattern: string, callbackPath: string): RegExp;
@@ -17,8 +25,13 @@ export type RedirectUriRules = {
17
25
  previewUrlPattern?: string | null;
18
26
  redirectUris?: readonly string[] | null;
19
27
  callbackPath?: string;
28
+ /** Public clients only: also accept the native redirect URIs listed in `redirectUris`. */
29
+ allowNativeRedirects?: boolean;
20
30
  };
21
- /** Exact match on `websiteUrl + callbackPath`, an explicit extra redirect URI, or the preview pattern. */
31
+ /**
32
+ * Exact match on `websiteUrl + callbackPath`, an explicit extra redirect URI, or the preview pattern. Native redirect URIs
33
+ * (see `isNativeRedirectUri`) are only accepted for public clients, and only when listed in `redirectUris`.
34
+ */
22
35
  export declare function validateRedirectUri(redirectUri: string, rules: RedirectUriRules): boolean;
23
36
  /** Prefixed random id, e.g. `newId('app')` -> `app_3f9c…`. */
24
37
  export declare function newId(prefix: string): string;
@@ -1,5 +1,24 @@
1
1
  export const DEFAULT_SCOPES = ["openid", "profile", "email"];
2
2
  export const DEFAULT_CALLBACK_PATH = "/auth/frigear";
3
+ export const CLIENT_TYPES = ["confidential", "public"];
4
+ const NON_NATIVE_SCHEMES = /* @__PURE__ */ new Set([
5
+ "http:",
6
+ "https:",
7
+ "ws:",
8
+ "wss:",
9
+ "ftp:",
10
+ "file:",
11
+ "javascript:",
12
+ "data:",
13
+ "blob:",
14
+ "vbscript:",
15
+ "about:",
16
+ "mailto:",
17
+ "tel:",
18
+ "sms:",
19
+ "chrome:",
20
+ "view-source:"
21
+ ]);
3
22
  export function matchesAllowedRoles(allowedRoles, roles) {
4
23
  if (!allowedRoles || allowedRoles.length === 0) return true;
5
24
  return allowedRoles.some((role) => roles.includes(role));
@@ -16,6 +35,17 @@ export function isSecureOrigin(url) {
16
35
  if (url.protocol === "https:") return true;
17
36
  return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
18
37
  }
38
+ export function isNativeRedirectUri(value) {
39
+ let url;
40
+ try {
41
+ url = typeof value === "string" ? new URL(value) : value;
42
+ } catch {
43
+ return false;
44
+ }
45
+ if (!/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) || NON_NATIVE_SCHEMES.has(url.protocol)) return false;
46
+ if (url.username || url.password || url.hash) return false;
47
+ return !url.href.includes("*");
48
+ }
19
49
  export function normalizeWebsiteUrl(value) {
20
50
  let url;
21
51
  try {
@@ -40,7 +70,8 @@ export function validateRedirectUri(redirectUri, rules) {
40
70
  } catch {
41
71
  return false;
42
72
  }
43
- if (url.hash || !isSecureOrigin(url)) return false;
73
+ if (url.hash) return false;
74
+ if (!isSecureOrigin(url)) return rules.allowNativeRedirects === true && isNativeRedirectUri(url) && !!rules.redirectUris?.includes(redirectUri);
44
75
  if (redirectUri === `${rules.websiteUrl.replace(/\/+$/, "")}${callbackPath}`) return true;
45
76
  if (rules.redirectUris?.includes(redirectUri)) return true;
46
77
  if (rules.previewUrlPattern) return previewPatternToRegExp(rules.previewUrlPattern, callbackPath).test(redirectUri);
package/dist/types.d.mts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { default } from './module.mjs'
2
2
 
3
- export { type AdminApplicationDto, type ApplicationAccessContext, type ApplicationInput, type AuthorizeContext, type ClaimsContext, type ConsentDto, type CreatedApplicationDto, type ModuleOptions, type OAuthApplication, type OAuthServerRuntimeConfig, type OAuthUser, type PublicModuleOptions, type TokenIssuedContext, type TokenRevokedContext, type TokenType, type UserApplicationDto, type UserRolesContext } from './module.mjs'
3
+ export { type AdminApplicationDto, type ApplicationAccessContext, type ApplicationInput, type AuthorizeContext, type ClaimsContext, type ClientType, type ConsentDto, type CreatedApplicationDto, type ModuleOptions, type OAuthApplication, type OAuthServerRuntimeConfig, type OAuthUser, type PublicModuleOptions, type TokenIssuedContext, type TokenRevokedContext, type TokenType, type UserApplicationDto, type UserRolesContext } from './module.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frigear-nu/oauth-server",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OAuth 2.0 / OpenID Connect server for the frigear ecosystem: register applications that users with certain roles may sign in to.",
5
5
  "url": "https://docs.frigear.dev/modules/oauth-server",
6
6
  "author": {
@@ -37,12 +37,12 @@
37
37
  "playground"
38
38
  ],
39
39
  "dependencies": {
40
- "@frigear-nu/admin": "0.1.1",
41
- "@frigear-nu/module-kit": "1.1.1",
42
- "@frigear-nu/ui": "0.1.1",
43
- "@frigear-nu/auth": "0.1.2",
44
- "@frigear-nu/mcp": "0.1.1",
45
- "@frigear-nu/users": "1.1.2",
40
+ "@frigear-nu/admin": "0.1.2",
41
+ "@frigear-nu/module-kit": "1.1.2",
42
+ "@frigear-nu/ui": "0.1.2",
43
+ "@frigear-nu/auth": "0.1.3",
44
+ "@frigear-nu/mcp": "0.1.2",
45
+ "@frigear-nu/users": "1.1.3",
46
46
  "@libsql/client": "^0.18.0",
47
47
  "@nuxt/kit": "^4.6.1",
48
48
  "drizzle-kit": "^0.31.11",
@@ -52,8 +52,8 @@
52
52
  "zod": "^4.6.5"
53
53
  },
54
54
  "peerDependencies": {
55
- "@frigear-nu/menu": "0.1.1",
56
- "@nuxt/ui": "^4.11.2",
55
+ "@frigear-nu/menu": "0.1.2",
56
+ "@nuxt/ui": "^4.12.0",
57
57
  "@nuxthub/core": "^0.10.8"
58
58
  },
59
59
  "peerDependenciesMeta": {
@@ -65,13 +65,13 @@
65
65
  }
66
66
  },
67
67
  "devDependencies": {
68
- "@frigear-nu/menu": "0.1.1",
68
+ "@frigear-nu/menu": "0.1.2",
69
69
  "@nuxt/devtools": "^3.4.2",
70
70
  "@nuxt/eslint-config": "^1.17.0",
71
71
  "@nuxt/module-builder": "^1.0.3",
72
72
  "@nuxt/schema": "^4.6.1",
73
73
  "@nuxt/test-utils": "^4.3.2",
74
- "@nuxt/ui": "^4.11.2",
74
+ "@nuxt/ui": "^4.12.0",
75
75
  "@nuxthub/core": "^0.10.8",
76
76
  "@types/node": "^26.6.5",
77
77
  "changelogen": "^0.6.2",