@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.
- package/dist/module.d.mts +20 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +16 -1
- package/dist/runtime/app/components/OAuthApplicationSlideover.vue +41 -0
- package/dist/runtime/app/components/OAuthCredentialsModal.d.vue.ts +2 -1
- package/dist/runtime/app/components/OAuthCredentialsModal.vue +10 -3
- package/dist/runtime/app/components/OAuthCredentialsModal.vue.d.ts +2 -1
- package/dist/runtime/app/composables/useOAuthApplicationActions.js +3 -2
- package/dist/runtime/app/composables/useOAuthApplications.d.ts +5 -6
- package/dist/runtime/app/composables/useOAuthApplications.js +11 -5
- package/dist/runtime/app/pages/admin.vue +7 -0
- package/dist/runtime/app/pages/applications.vue +20 -2
- package/dist/runtime/i18n/lang/da.json +15 -3
- package/dist/runtime/i18n/lang/en.json +15 -3
- package/dist/runtime/server/bootstrap/my-applications.d.ts +7 -0
- package/dist/runtime/server/bootstrap/my-applications.js +4 -0
- package/dist/runtime/server/db/schema.postgresql.d.ts +17 -0
- package/dist/runtime/server/db/schema.postgresql.js +7 -1
- package/dist/runtime/server/db/schema.sqlite.d.ts +19 -0
- package/dist/runtime/server/db/schema.sqlite.js +7 -1
- package/dist/runtime/server/middleware/oauth-cors.d.ts +2 -0
- package/dist/runtime/server/middleware/oauth-cors.js +24 -0
- package/dist/runtime/server/routes/api/me-applications.get.js +2 -4
- package/dist/runtime/server/routes/oidc/openid-configuration.get.js +2 -1
- package/dist/runtime/server/routes/oidc/revoke.post.js +4 -4
- package/dist/runtime/server/routes/oidc/token.post.js +4 -4
- package/dist/runtime/server/utils/oauth-applications.d.ts +23 -2
- package/dist/runtime/server/utils/oauth-applications.js +42 -9
- package/dist/runtime/server/utils/oauth-authorize.js +1 -1
- package/dist/runtime/server/utils/oauth-config.d.ts +2 -0
- package/dist/runtime/server/utils/oauth-config.js +5 -0
- package/dist/runtime/server/utils/oauth-crypto.d.ts +9 -2
- package/dist/runtime/server/utils/oauth-crypto.js +10 -4
- package/dist/runtime/utils/index.d.ts +14 -1
- package/dist/runtime/utils/index.js +32 -1
- package/dist/types.d.mts +1 -1
- 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
|
-
|
|
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
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
43
|
+
const fetched = useCachedFetch(`${BASE}/me/applications`, {
|
|
44
44
|
key: "oauth-server:mine",
|
|
45
|
-
|
|
46
|
-
watch: false
|
|
47
|
-
|
|
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
|
-
|
|
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,
|
|
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="
|
|
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[]>;
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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,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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
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
|
|
71
|
-
if (!
|
|
72
|
-
|
|
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
|
|
12
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
|
|
47
|
-
return clientId
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
41
|
-
"@frigear-nu/module-kit": "1.1.
|
|
42
|
-
"@frigear-nu/ui": "0.1.
|
|
43
|
-
"@frigear-nu/auth": "0.1.
|
|
44
|
-
"@frigear-nu/mcp": "0.1.
|
|
45
|
-
"@frigear-nu/users": "1.1.
|
|
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.
|
|
56
|
-
"@nuxt/ui": "^4.
|
|
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.
|
|
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.
|
|
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",
|