@frigear-nu/oauth-server 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.d.mts +27 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +39 -20
- 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.d.ts +3 -0
- package/dist/runtime/app/composables/useOAuthApplicationActions.js +32 -15
- package/dist/runtime/app/composables/useOAuthApplications.d.ts +20 -20
- package/dist/runtime/app/composables/useOAuthApplications.js +35 -18
- package/dist/runtime/app/pages/admin.vue +116 -58
- package/dist/runtime/app/pages/applications.vue +59 -24
- package/dist/runtime/app/pages/authorize.vue +3 -2
- package/dist/runtime/i18n/lang/da.json +21 -4
- package/dist/runtime/i18n/lang/en.json +21 -4
- 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 +25 -0
- package/dist/runtime/server/routes/api/admin/application-revoke.post.js +5 -3
- package/dist/runtime/server/routes/api/admin/application-secret.post.js +3 -1
- package/dist/runtime/server/routes/api/admin/application.delete.js +4 -3
- package/dist/runtime/server/routes/api/admin/application.get.js +6 -3
- package/dist/runtime/server/routes/api/admin/application.patch.js +4 -3
- package/dist/runtime/server/routes/api/admin/applications.get.js +20 -8
- package/dist/runtime/server/routes/api/admin/applications.post.js +3 -3
- package/dist/runtime/server/routes/api/consent.get.js +4 -3
- package/dist/runtime/server/routes/api/me-applications.get.js +2 -4
- package/dist/runtime/server/routes/oidc/authorize.get.js +5 -6
- package/dist/runtime/server/routes/oidc/authorize.post.js +1 -2
- package/dist/runtime/server/routes/oidc/openid-configuration.get.js +8 -6
- package/dist/runtime/server/routes/oidc/revoke.post.js +19 -7
- package/dist/runtime/server/routes/oidc/token.post.js +24 -9
- package/dist/runtime/server/utils/oauth-access.d.ts +2 -0
- package/dist/runtime/server/utils/oauth-access.js +7 -1
- package/dist/runtime/server/utils/oauth-applications.d.ts +30 -4
- package/dist/runtime/server/utils/oauth-applications.js +77 -17
- package/dist/runtime/server/utils/oauth-authorize.d.ts +8 -4
- package/dist/runtime/server/utils/oauth-authorize.js +11 -9
- package/dist/runtime/server/utils/oauth-config.d.ts +7 -0
- package/dist/runtime/server/utils/oauth-config.js +10 -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/server/utils/oauth-keys.js +3 -2
- package/dist/runtime/utils/api.d.ts +28 -0
- package/dist/runtime/utils/api.js +27 -0
- package/dist/runtime/utils/index.d.ts +15 -1
- package/dist/runtime/utils/index.js +33 -1
- package/dist/types.d.mts +1 -1
- package/package.json +16 -11
|
@@ -1,11 +1,23 @@
|
|
|
1
|
+
import { db, schema } from "@nuxthub/db";
|
|
2
|
+
import { count, or } from "drizzle-orm";
|
|
3
|
+
import { likeContains, orderByQuery, pageQuery, pageRange, paginate } from "@frigear-nu/module-kit/server";
|
|
4
|
+
import { toApplication, toAdminApplicationDto } from "../../../utils/oauth-applications.js";
|
|
5
|
+
const querySchema = pageQuery({ sort: ["createdAt", "name", "priority"] });
|
|
1
6
|
export default defineEventHandler(async (event) => {
|
|
2
7
|
await requireOAuthAdmin(event);
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const query = await getValidatedQuery(event, querySchema.parse);
|
|
9
|
+
const table = schema.oauthApplications;
|
|
10
|
+
const where = query.q ? or(likeContains(table.name, query.q), likeContains(table.id, query.q)) : void 0;
|
|
11
|
+
const orderBy = orderByQuery({ createdAt: table.createdAt, name: table.name, priority: table.priority }, query, { tiebreaker: table.id });
|
|
12
|
+
const { limit, offset } = pageRange(query);
|
|
13
|
+
const [rows, [totals]] = await Promise.all([
|
|
14
|
+
db.select().from(table).where(where).orderBy(...orderBy).limit(limit).offset(offset),
|
|
15
|
+
db.select({ total: count() }).from(table).where(where)
|
|
16
|
+
]);
|
|
17
|
+
const grants = await countActiveGrants(rows.map((row) => row.id));
|
|
18
|
+
return paginate({
|
|
19
|
+
total: totals?.total ?? 0,
|
|
20
|
+
query,
|
|
21
|
+
items: rows.map((row) => toAdminApplicationDto(toApplication(row), grants[row.id] ?? 0))
|
|
22
|
+
});
|
|
11
23
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { created } from "@frigear-nu/module-kit/server";
|
|
2
|
+
import { applicationInputSchema, toAdminApplicationDto } from "../../../utils/oauth-applications.js";
|
|
2
3
|
export default defineEventHandler(async (event) => {
|
|
3
4
|
const { userId } = await requireOAuthAdmin(event);
|
|
4
5
|
const input = await readValidatedBody(event, applicationInputSchema.parse);
|
|
5
6
|
const { application, secret } = await createApplication(input, userId);
|
|
6
|
-
|
|
7
|
-
return { ...application, clientId: application.id, clientSecret: secret };
|
|
7
|
+
return created(event, { ...toAdminApplicationDto(application, 0), clientId: application.id, clientSecret: secret });
|
|
8
8
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { getQuery } from "h3";
|
|
2
|
+
import { parseAuthorizeRequest } from "../../utils/oauth-authorize.js";
|
|
2
3
|
export default defineEventHandler(async (event) => {
|
|
3
4
|
const userId = await requireSessionUserId(event);
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const query = parseAuthorizeRequest(getQuery(event));
|
|
6
|
+
const { application, scope } = await validateAuthorizeRequest(query);
|
|
7
|
+
const user = await requireOAuthUser(userId);
|
|
7
8
|
await assertApplicationAccess(event, user, application);
|
|
8
9
|
return {
|
|
9
10
|
application: {
|
|
@@ -1,4 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
return listUserApplications(userId, event);
|
|
4
|
-
});
|
|
1
|
+
import myApplications from "../../bootstrap/my-applications.js";
|
|
2
|
+
export default defineEventHandler(myApplications);
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { parseAuthorizeRequest } from "../../utils/oauth-authorize.js";
|
|
2
3
|
export default defineEventHandler(async (event) => {
|
|
3
|
-
const query = getQuery(event);
|
|
4
|
+
const query = parseAuthorizeRequest(getQuery(event));
|
|
4
5
|
const { application } = await validateAuthorizeRequest(query);
|
|
6
|
+
const url = getRequestURL(event);
|
|
5
7
|
const session = await getUserSession(event);
|
|
6
8
|
if (!session.user) {
|
|
7
|
-
|
|
8
|
-
return sendRedirect(event, `${getSignInPath()}?redirect=${encodeURIComponent(`${url2.pathname}${url2.search}`)}`, 302);
|
|
9
|
+
return sendRedirect(event, `${getSignInPath()}?redirect=${encodeURIComponent(`${url.pathname}${url.search}`)}`, 302);
|
|
9
10
|
}
|
|
10
|
-
const user = await
|
|
11
|
-
if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
|
|
11
|
+
const user = await requireOAuthUser(await requireSessionUserId(event));
|
|
12
12
|
await assertApplicationAccess(event, user, application);
|
|
13
|
-
const url = getRequestURL(event);
|
|
14
13
|
return sendRedirect(event, `${useOAuthConfig().authorizePath}${url.search}`, 302);
|
|
15
14
|
});
|
|
@@ -11,8 +11,7 @@ export default defineEventHandler(async (event) => {
|
|
|
11
11
|
redirect.searchParams.set("error", "access_denied");
|
|
12
12
|
return { redirectUrl: redirect.toString() };
|
|
13
13
|
}
|
|
14
|
-
const user = await
|
|
15
|
-
if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
|
|
14
|
+
const user = await requireOAuthUser(userId);
|
|
16
15
|
await assertApplicationAccess(event, user, application);
|
|
17
16
|
const code = await createAuthorizationCode({
|
|
18
17
|
application,
|
|
@@ -1,20 +1,22 @@
|
|
|
1
|
+
import { oidc } from "../../../utils/api.js";
|
|
1
2
|
export default defineEventHandler((event) => {
|
|
2
3
|
const issuer = getOAuthIssuer(event);
|
|
3
4
|
setResponseHeader(event, "Cache-Control", "public, max-age=3600");
|
|
4
5
|
return {
|
|
5
6
|
issuer,
|
|
6
|
-
authorization_endpoint: `${issuer}
|
|
7
|
-
token_endpoint: `${issuer}
|
|
8
|
-
userinfo_endpoint: `${issuer}
|
|
9
|
-
revocation_endpoint: `${issuer}
|
|
10
|
-
jwks_uri: `${issuer}
|
|
7
|
+
authorization_endpoint: `${issuer}${oidc.authorize}`,
|
|
8
|
+
token_endpoint: `${issuer}${oidc.token}`,
|
|
9
|
+
userinfo_endpoint: `${issuer}${oidc.userinfo}`,
|
|
10
|
+
revocation_endpoint: `${issuer}${oidc.revoke}`,
|
|
11
|
+
jwks_uri: `${issuer}${oidc.jwks}`,
|
|
11
12
|
response_types_supported: ["code"],
|
|
12
13
|
response_modes_supported: ["query"],
|
|
13
14
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
14
15
|
subject_types_supported: ["public"],
|
|
15
16
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
16
17
|
scopes_supported: getSupportedScopes(),
|
|
17
|
-
|
|
18
|
+
// `none`: public clients (native apps, SPAs) identify with `client_id` and PKCE only.
|
|
19
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
18
20
|
code_challenge_methods_supported: ["S256"],
|
|
19
21
|
claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "picture", "email"]
|
|
20
22
|
};
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const param = z.string().max(4096).optional();
|
|
3
|
+
const bodySchema = z.object({
|
|
4
|
+
token: param,
|
|
5
|
+
token_type_hint: param,
|
|
6
|
+
client_id: param,
|
|
7
|
+
client_secret: param
|
|
8
|
+
});
|
|
2
9
|
export default defineEventHandler(async (event) => {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
10
|
+
let body;
|
|
11
|
+
try {
|
|
12
|
+
body = await readValidatedBody(event, bodySchema.parse);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
return oauthError(event, 400, "invalid_request", invalidRequestDescription(error));
|
|
15
|
+
}
|
|
16
|
+
const client = await authenticateClient(event, body);
|
|
17
|
+
if (!("row" in client)) {
|
|
18
|
+
if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
|
|
8
19
|
return oauthError(event, 401, "invalid_client", "Client authentication failed.");
|
|
9
20
|
}
|
|
10
|
-
|
|
21
|
+
const row = client.row;
|
|
22
|
+
if (body.token) await revokeRefreshToken(body.token, row.id);
|
|
11
23
|
setResponseStatus(event, 200);
|
|
12
24
|
return {};
|
|
13
25
|
});
|
|
@@ -1,21 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const param = z.string().max(4096).optional();
|
|
3
|
+
const bodySchema = z.object({
|
|
4
|
+
grant_type: param,
|
|
5
|
+
code: param,
|
|
6
|
+
redirect_uri: param,
|
|
7
|
+
code_verifier: param,
|
|
8
|
+
refresh_token: param,
|
|
9
|
+
client_id: param,
|
|
10
|
+
client_secret: param
|
|
11
|
+
});
|
|
2
12
|
export default defineEventHandler(async (event) => {
|
|
3
13
|
setResponseHeader(event, "Cache-Control", "no-store");
|
|
4
14
|
setResponseHeader(event, "Pragma", "no-cache");
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
let body;
|
|
16
|
+
try {
|
|
17
|
+
body = await readValidatedBody(event, bodySchema.parse);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return oauthError(event, 400, "invalid_request", invalidRequestDescription(error));
|
|
20
|
+
}
|
|
21
|
+
const client = await authenticateClient(event, body);
|
|
22
|
+
if (!("row" in client)) {
|
|
23
|
+
if (client.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
|
|
10
24
|
return oauthError(event, 401, "invalid_client", "Client authentication failed.");
|
|
11
25
|
}
|
|
26
|
+
const row = client.row;
|
|
12
27
|
const application = toApplication(row);
|
|
13
|
-
const grantType = body
|
|
28
|
+
const grantType = body.grant_type;
|
|
14
29
|
let grant;
|
|
15
30
|
let refreshToken;
|
|
16
31
|
if (grantType === "authorization_code") {
|
|
17
32
|
const { code, redirect_uri: redirectUri, code_verifier: codeVerifier } = body;
|
|
18
|
-
if (
|
|
33
|
+
if (!code || !redirectUri || !codeVerifier) {
|
|
19
34
|
return oauthError(event, 400, "invalid_request", "code, redirect_uri and code_verifier are required.");
|
|
20
35
|
}
|
|
21
36
|
const result = await exchangeAuthorizationCode({ code, application, redirectUri, codeVerifier });
|
|
@@ -23,7 +38,7 @@ export default defineEventHandler(async (event) => {
|
|
|
23
38
|
grant = result.grant;
|
|
24
39
|
refreshToken = (await createRefreshToken({ applicationId: application.id, ...grant })).token;
|
|
25
40
|
} else if (grantType === "refresh_token") {
|
|
26
|
-
if (
|
|
41
|
+
if (!body.refresh_token) return oauthError(event, 400, "invalid_request", "refresh_token is required.");
|
|
27
42
|
const result = await rotateRefreshToken({ token: body.refresh_token, application });
|
|
28
43
|
if ("error" in result) return oauthError(event, 400, "invalid_grant", result.error);
|
|
29
44
|
grant = result.grant;
|
|
@@ -3,6 +3,8 @@ import type { OAuthApplication, OAuthUser, TokenType } from '../../../types.js';
|
|
|
3
3
|
export declare function requireOAuthAdmin(event: H3Event): Promise<{
|
|
4
4
|
userId: number;
|
|
5
5
|
}>;
|
|
6
|
+
/** The signed-in user as stored, or 401 `unauthorized` when the account no longer exists. */
|
|
7
|
+
export declare function requireOAuthUser(userId: number): Promise<OAuthUser>;
|
|
6
8
|
/** The user's roles as reported by the `oauth:user:roles` hook listeners (none without a listener). */
|
|
7
9
|
export declare function resolveUserRoles(userId: number, event?: H3Event): Promise<string[]>;
|
|
8
10
|
/** The stored user without the password hash; includes columns the host added to `users`. */
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { apiError, forbidden } from "@frigear-nu/module-kit/server";
|
|
1
2
|
import { matchesAllowedRoles } from "../../utils/index.js";
|
|
2
3
|
const adminGate = definePolicyGate("oauth:authorize");
|
|
3
4
|
export async function requireOAuthAdmin(event) {
|
|
4
5
|
const userId = await requireSessionUserId(event);
|
|
5
|
-
await adminGate.
|
|
6
|
+
if (!await adminGate.isAllowed(event, userId)) throw forbidden("You are not allowed to manage OAuth applications.");
|
|
6
7
|
return { userId };
|
|
7
8
|
}
|
|
9
|
+
export async function requireOAuthUser(userId) {
|
|
10
|
+
const user = await getOAuthUser(userId);
|
|
11
|
+
if (!user) throw apiError(401, "unauthorized", "Unknown user.");
|
|
12
|
+
return user;
|
|
13
|
+
}
|
|
8
14
|
export async function resolveUserRoles(userId, event) {
|
|
9
15
|
const ctx = { event, userId, roles: [] };
|
|
10
16
|
await useNitroApp().hooks.callHook("oauth:user:roles", ctx);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { schema } from '@nuxthub/db';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { H3Event } from 'h3';
|
|
4
|
-
import type { ApplicationInput, OAuthApplication, UserApplicationDto } from '../../../types.js';
|
|
4
|
+
import type { AdminApplicationDto, ApplicationInput, OAuthApplication, UserApplicationDto } from '../../../types.js';
|
|
5
5
|
type ApplicationRow = typeof schema.oauthApplications.$inferSelect;
|
|
6
6
|
/** Validation shared by the admin API and `createApplication` / `updateApplication`. */
|
|
7
7
|
export declare const applicationInputSchema: z.ZodObject<{
|
|
8
|
+
clientType: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
confidential: "confidential";
|
|
10
|
+
public: "public";
|
|
11
|
+
}>>;
|
|
8
12
|
name: z.ZodString;
|
|
9
13
|
websiteUrl: z.ZodString;
|
|
10
14
|
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
@@ -18,6 +22,10 @@ export declare const applicationInputSchema: z.ZodObject<{
|
|
|
18
22
|
isActive: z.ZodOptional<z.ZodBoolean>;
|
|
19
23
|
}, z.core.$strip>;
|
|
20
24
|
export declare const applicationPatchSchema: z.ZodObject<{
|
|
25
|
+
clientType: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
|
|
26
|
+
confidential: "confidential";
|
|
27
|
+
public: "public";
|
|
28
|
+
}>>>;
|
|
21
29
|
name: z.ZodOptional<z.ZodString>;
|
|
22
30
|
websiteUrl: z.ZodOptional<z.ZodString>;
|
|
23
31
|
description: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
@@ -31,14 +39,22 @@ export declare const applicationPatchSchema: z.ZodObject<{
|
|
|
31
39
|
isActive: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
|
|
32
40
|
}, z.core.$strip>;
|
|
33
41
|
export declare function toApplication(row: ApplicationRow): OAuthApplication;
|
|
42
|
+
/** The redirect URI an application has to accept on its side: its website plus the configured callback path. */
|
|
43
|
+
export declare function applicationCallbackUrl(application: Pick<OAuthApplication, 'websiteUrl'>): string;
|
|
44
|
+
/** The admin view of an application (never the secret hash), with the active sign-ins when counted. */
|
|
45
|
+
export declare function toAdminApplicationDto(application: OAuthApplication, activeGrants?: number): AdminApplicationDto;
|
|
46
|
+
/** One application for a staff route, or 404 `not_found`. */
|
|
47
|
+
export declare function requireApplication(id: string): Promise<OAuthApplication>;
|
|
34
48
|
/** The row including `secretHash`; only for credential checks. Use `getApplication` everywhere else. */
|
|
35
49
|
export declare function getApplicationRow(id: string): Promise<ApplicationRow | null>;
|
|
36
50
|
export declare function getApplication(id: string): Promise<OAuthApplication | null>;
|
|
37
|
-
|
|
38
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Creates an application. The returned `secret` is the only time the plain secret is available; it is `null` for
|
|
53
|
+
* `public` clients, which have none.
|
|
54
|
+
*/
|
|
39
55
|
export declare function createApplication(input: ApplicationInput, ownerId?: number | null): Promise<{
|
|
40
56
|
application: OAuthApplication;
|
|
41
|
-
secret: string;
|
|
57
|
+
secret: string | null;
|
|
42
58
|
}>;
|
|
43
59
|
export declare function updateApplication(id: string, patch: Partial<ApplicationInput>): Promise<OAuthApplication>;
|
|
44
60
|
/** Deletes the application with its authorization codes and refresh tokens. */
|
|
@@ -48,6 +64,16 @@ export declare function rotateApplicationSecret(id: string): Promise<{
|
|
|
48
64
|
secret: string;
|
|
49
65
|
}>;
|
|
50
66
|
export declare function verifyApplicationSecret(row: ApplicationRow, secret: string): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Authenticates the client of a token or revocation request. Confidential clients must present their secret. Public
|
|
69
|
+
* clients are identified by `client_id` alone (their security is PKCE plus refresh token rotation); a secret sent to one
|
|
70
|
+
* is refused, as it can only mean the app was configured with the wrong kind of client.
|
|
71
|
+
*/
|
|
72
|
+
export declare function authenticateClient(event: H3Event, body: Record<string, unknown> | undefined): Promise<{
|
|
73
|
+
row: ApplicationRow;
|
|
74
|
+
} | {
|
|
75
|
+
basic: boolean;
|
|
76
|
+
}>;
|
|
51
77
|
/** Revokes every refresh token of an application (e.g. after a secret leak). */
|
|
52
78
|
export declare function revokeApplicationGrants(applicationId: string): Promise<void>;
|
|
53
79
|
/**
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
2
2
|
import { db, schema } from "@nuxthub/db";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { badRequest, isoDate, notFound } from "@frigear-nu/module-kit/server";
|
|
5
|
+
import { CLIENT_TYPES, isNativeRedirectUri, isSecureOrigin, matchesAllowedRoles, newId, normalizeWebsiteUrl } from "../../utils/index.js";
|
|
6
|
+
const applicationNotFound = () => notFound("Application not found.");
|
|
6
7
|
const httpsUrl = z.string().trim().max(2048).refine((value) => {
|
|
7
8
|
try {
|
|
8
9
|
return isSecureOrigin(new URL(value));
|
|
@@ -10,8 +11,17 @@ const httpsUrl = z.string().trim().max(2048).refine((value) => {
|
|
|
10
11
|
return false;
|
|
11
12
|
}
|
|
12
13
|
}, "Must be a https URL (http is only allowed for localhost).");
|
|
14
|
+
const redirectUri = z.string().trim().max(2048).refine((value) => {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return !url.hash && (isSecureOrigin(url) || isNativeRedirectUri(url));
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}, "Must be a https URL (http is only allowed for localhost) or, for public clients, a custom-scheme URI.");
|
|
13
22
|
const names = z.array(z.string().trim().min(1).max(64)).max(50);
|
|
14
23
|
export const applicationInputSchema = z.object({
|
|
24
|
+
clientType: z.enum(CLIENT_TYPES).optional(),
|
|
15
25
|
name: z.string().trim().min(1).max(120),
|
|
16
26
|
websiteUrl: z.string().trim().max(2048),
|
|
17
27
|
description: z.string().trim().max(1e3).nullish(),
|
|
@@ -19,7 +29,7 @@ export const applicationInputSchema = z.object({
|
|
|
19
29
|
priority: z.number().int().min(-1e3).max(1e3).optional(),
|
|
20
30
|
loginUrl: httpsUrl.nullish(),
|
|
21
31
|
previewUrlPattern: z.string().trim().max(2048).startsWith("https://").nullish(),
|
|
22
|
-
redirectUris: z.array(
|
|
32
|
+
redirectUris: z.array(redirectUri).max(20).optional(),
|
|
23
33
|
allowedRoles: names.optional(),
|
|
24
34
|
tags: names.optional(),
|
|
25
35
|
isActive: z.boolean().optional()
|
|
@@ -29,19 +39,54 @@ export function toApplication(row) {
|
|
|
29
39
|
const { secretHash: _secretHash, ...application } = row;
|
|
30
40
|
return application;
|
|
31
41
|
}
|
|
42
|
+
export function applicationCallbackUrl(application) {
|
|
43
|
+
return `${application.websiteUrl}${useOAuthConfig().callbackPath}`;
|
|
44
|
+
}
|
|
45
|
+
export function toAdminApplicationDto(application, activeGrants) {
|
|
46
|
+
return {
|
|
47
|
+
id: application.id,
|
|
48
|
+
clientType: application.clientType,
|
|
49
|
+
name: application.name,
|
|
50
|
+
description: application.description,
|
|
51
|
+
icon: application.icon,
|
|
52
|
+
priority: application.priority,
|
|
53
|
+
websiteUrl: application.websiteUrl,
|
|
54
|
+
loginUrl: application.loginUrl,
|
|
55
|
+
previewUrlPattern: application.previewUrlPattern,
|
|
56
|
+
redirectUris: application.redirectUris,
|
|
57
|
+
allowedRoles: application.allowedRoles,
|
|
58
|
+
tags: application.tags,
|
|
59
|
+
ownerId: application.ownerId,
|
|
60
|
+
isActive: application.isActive,
|
|
61
|
+
createdAt: isoDate(application.createdAt),
|
|
62
|
+
updatedAt: isoDate(application.updatedAt),
|
|
63
|
+
...activeGrants === void 0 ? {} : { activeGrants },
|
|
64
|
+
callbackUrl: applicationCallbackUrl(application)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function requireApplication(id) {
|
|
68
|
+
const application = await getApplication(id);
|
|
69
|
+
if (!application) throw applicationNotFound();
|
|
70
|
+
return application;
|
|
71
|
+
}
|
|
32
72
|
function normalizeInput(input) {
|
|
33
73
|
const next = { ...input };
|
|
34
74
|
if (next.websiteUrl !== void 0) {
|
|
35
75
|
try {
|
|
36
76
|
next.websiteUrl = normalizeWebsiteUrl(next.websiteUrl);
|
|
37
77
|
} catch (error) {
|
|
38
|
-
throw
|
|
78
|
+
throw badRequest(error.message);
|
|
39
79
|
}
|
|
40
80
|
}
|
|
81
|
+
if (next.redirectUris) next.redirectUris = [...new Set(next.redirectUris)];
|
|
41
82
|
if (next.allowedRoles) next.allowedRoles = [...new Set(next.allowedRoles)].sort();
|
|
42
83
|
if (next.tags) next.tags = [...new Set(next.tags)];
|
|
43
84
|
return next;
|
|
44
85
|
}
|
|
86
|
+
function assertRedirectUris(uris, clientType) {
|
|
87
|
+
if (clientType === "public" || !uris) return;
|
|
88
|
+
if (uris.some((uri) => !isSecureOrigin(new URL(uri)))) throw badRequest("Only public clients may use custom-scheme redirect URIs.");
|
|
89
|
+
}
|
|
45
90
|
export async function getApplicationRow(id) {
|
|
46
91
|
const [row] = await db.select().from(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).limit(1);
|
|
47
92
|
return row ?? null;
|
|
@@ -50,43 +95,58 @@ export async function getApplication(id) {
|
|
|
50
95
|
const row = await getApplicationRow(id);
|
|
51
96
|
return row ? toApplication(row) : null;
|
|
52
97
|
}
|
|
53
|
-
export async function listApplications() {
|
|
54
|
-
const rows = await db.select().from(schema.oauthApplications).orderBy(desc(schema.oauthApplications.priority), schema.oauthApplications.name);
|
|
55
|
-
return rows.map(toApplication);
|
|
56
|
-
}
|
|
57
98
|
export async function createApplication(input, ownerId) {
|
|
58
99
|
const data = normalizeInput(applicationInputSchema.parse(input));
|
|
59
|
-
const
|
|
100
|
+
const clientType = data.clientType ?? "confidential";
|
|
101
|
+
assertRedirectUris(data.redirectUris, clientType);
|
|
102
|
+
const secret = clientType === "public" ? null : generateSecret();
|
|
60
103
|
const [row] = await db.insert(schema.oauthApplications).values({
|
|
61
104
|
id: newId("app"),
|
|
62
|
-
secretHash: await sha256Hex(secret),
|
|
63
105
|
ownerId: ownerId ?? null,
|
|
64
|
-
...data
|
|
106
|
+
...data,
|
|
107
|
+
clientType,
|
|
108
|
+
// A public client has no secret to hash; see `secretHash` in the schema.
|
|
109
|
+
secretHash: await sha256Hex(secret ?? generateSecret())
|
|
65
110
|
}).returning();
|
|
66
111
|
return { application: toApplication(row), secret };
|
|
67
112
|
}
|
|
68
113
|
export async function updateApplication(id, patch) {
|
|
69
|
-
const data = normalizeInput(applicationPatchSchema.parse(patch));
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
72
|
-
|
|
114
|
+
const { clientType, ...data } = normalizeInput(applicationPatchSchema.parse(patch));
|
|
115
|
+
const existing = await getApplicationRow(id);
|
|
116
|
+
if (!existing) throw applicationNotFound();
|
|
117
|
+
if (clientType && clientType !== existing.clientType) throw badRequest("The client type cannot be changed.");
|
|
118
|
+
assertRedirectUris(data.redirectUris, existing.clientType);
|
|
119
|
+
if (!Object.keys(data).length) return toApplication(existing);
|
|
120
|
+
const [row] = await db.update(schema.oauthApplications).set(data).where(eq(schema.oauthApplications.id, id)).returning();
|
|
121
|
+
return toApplication(row ?? existing);
|
|
73
122
|
}
|
|
74
123
|
export async function deleteApplication(id) {
|
|
75
124
|
await db.delete(schema.oauthRefreshTokens).where(eq(schema.oauthRefreshTokens.applicationId, id));
|
|
76
125
|
await db.delete(schema.oauthAuthorizationCodes).where(eq(schema.oauthAuthorizationCodes.applicationId, id));
|
|
77
126
|
const deleted = await db.delete(schema.oauthApplications).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
|
|
78
|
-
if (!deleted.length) throw
|
|
127
|
+
if (!deleted.length) throw applicationNotFound();
|
|
79
128
|
}
|
|
80
129
|
export async function rotateApplicationSecret(id) {
|
|
130
|
+
const existing = await getApplicationRow(id);
|
|
131
|
+
if (existing?.clientType === "public") throw badRequest("Public clients have no client secret.");
|
|
81
132
|
const secret = generateSecret();
|
|
82
133
|
const updated = await db.update(schema.oauthApplications).set({ secretHash: await sha256Hex(secret) }).where(eq(schema.oauthApplications.id, id)).returning({ id: schema.oauthApplications.id });
|
|
83
|
-
if (!updated.length) throw
|
|
134
|
+
if (!updated.length) throw applicationNotFound();
|
|
84
135
|
await revokeApplicationGrants(id);
|
|
85
136
|
return { secret };
|
|
86
137
|
}
|
|
87
138
|
export async function verifyApplicationSecret(row, secret) {
|
|
88
139
|
return safeEqual(await sha256Hex(secret), row.secretHash);
|
|
89
140
|
}
|
|
141
|
+
export async function authenticateClient(event, body) {
|
|
142
|
+
const auth = readClientAuth(event, body);
|
|
143
|
+
const row = auth ? await getApplicationRow(auth.clientId) : null;
|
|
144
|
+
const basic = auth?.basic ?? false;
|
|
145
|
+
if (!auth || !row) return { basic };
|
|
146
|
+
if (row.clientType === "public") return auth.clientSecret ? { basic } : { row };
|
|
147
|
+
if (!auth.clientSecret || !await verifyApplicationSecret(row, auth.clientSecret)) return { basic };
|
|
148
|
+
return { row };
|
|
149
|
+
}
|
|
90
150
|
export async function revokeApplicationGrants(applicationId) {
|
|
91
151
|
await db.update(schema.oauthRefreshTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(eq(schema.oauthRefreshTokens.applicationId, applicationId));
|
|
92
152
|
await useNitroApp().hooks.callHook("oauth:token:revoked", { applicationId, userId: null, reason: "revoked" });
|
|
@@ -14,11 +14,15 @@ export declare const authorizeRequestSchema: z.ZodObject<{
|
|
|
14
14
|
}, z.core.$strip>;
|
|
15
15
|
export type AuthorizeRequest = z.infer<typeof authorizeRequestSchema>;
|
|
16
16
|
/**
|
|
17
|
-
* Parses
|
|
18
|
-
*
|
|
19
|
-
* be trusted to carry them yet.
|
|
17
|
+
* Parses the authorization request parameters (a query or a body). Failures are a
|
|
18
|
+
* plain 400 `bad_request` naming the bad parameters, because the redirect URI cannot be trusted to carry them yet.
|
|
20
19
|
*/
|
|
21
|
-
export declare function
|
|
20
|
+
export declare function parseAuthorizeRequest(input: unknown): AuthorizeRequest;
|
|
21
|
+
/**
|
|
22
|
+
* Validates a parsed authorization request: known, active client, an allowed redirect URI and the scopes (PKCE with
|
|
23
|
+
* S256 is enforced by the schema). Failures are plain HTTP errors because the redirect URI cannot be trusted yet.
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateAuthorizeRequest(request: AuthorizeRequest): Promise<{
|
|
22
26
|
request: AuthorizeRequest;
|
|
23
27
|
application: OAuthApplication;
|
|
24
28
|
scope: string[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { badRequest, forbidden } from "@frigear-nu/module-kit/server";
|
|
2
3
|
import { resolveScope, validateRedirectUri } from "../../utils/index.js";
|
|
3
4
|
export const authorizeRequestSchema = z.object({
|
|
4
5
|
client_id: z.string().min(1),
|
|
@@ -10,22 +11,23 @@ export const authorizeRequestSchema = z.object({
|
|
|
10
11
|
scope: z.string().max(1024).optional(),
|
|
11
12
|
nonce: z.string().max(512).optional()
|
|
12
13
|
});
|
|
13
|
-
|
|
14
|
-
export async function validateAuthorizeRequest(input) {
|
|
14
|
+
export function parseAuthorizeRequest(input) {
|
|
15
15
|
const parsed = authorizeRequestSchema.safeParse(input);
|
|
16
|
-
if (!parsed.success) throw
|
|
17
|
-
|
|
16
|
+
if (!parsed.success) throw badRequest(`Invalid authorization request: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
|
|
17
|
+
return parsed.data;
|
|
18
|
+
}
|
|
19
|
+
export async function validateAuthorizeRequest(request) {
|
|
18
20
|
const application = await getApplication(request.client_id);
|
|
19
|
-
if (!application || !application.isActive) throw
|
|
20
|
-
const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath });
|
|
21
|
-
if (!valid) throw
|
|
21
|
+
if (!application || !application.isActive) throw badRequest("Unknown or disabled application.");
|
|
22
|
+
const valid = validateRedirectUri(request.redirect_uri, { ...application, callbackPath: useOAuthConfig().callbackPath, allowNativeRedirects: application.clientType === "public" });
|
|
23
|
+
if (!valid) throw badRequest("The redirect_uri is not allowed for this application.");
|
|
22
24
|
const scope = resolveScope(request.scope, getSupportedScopes());
|
|
23
|
-
if (scope.length === 0) throw
|
|
25
|
+
if (scope.length === 0) throw badRequest("None of the requested scopes is supported.");
|
|
24
26
|
return { request, application, scope };
|
|
25
27
|
}
|
|
26
28
|
export async function assertApplicationAccess(event, user, application) {
|
|
27
29
|
const access = await checkApplicationAccess(user, application, event);
|
|
28
|
-
if (!access.allowed) throw
|
|
30
|
+
if (!access.allowed) throw forbidden(access.reason ?? "You do not have access to this application.");
|
|
29
31
|
}
|
|
30
32
|
export function getSignInPath() {
|
|
31
33
|
const config = useRuntimeConfig();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { H3Event } from 'h3';
|
|
2
2
|
export declare function useOAuthConfig(): any;
|
|
3
|
+
/** Origins allowed to call the token, revocation, userinfo and discovery endpoints from a browser. Never `*`. */
|
|
4
|
+
export declare function getOAuthCorsOrigins(): string[];
|
|
3
5
|
/** The configured issuer, or the origin the request came in on (honouring `x-forwarded-*`). */
|
|
4
6
|
export declare function getOAuthIssuer(event: H3Event): string;
|
|
5
7
|
/**
|
|
@@ -17,3 +19,8 @@ export declare function oauthError(event: H3Event, statusCode: number, error: st
|
|
|
17
19
|
error_description?: string | undefined;
|
|
18
20
|
error: string;
|
|
19
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* The `error_description` for a token or revocation request whose body failed `readValidatedBody` (h3's 400 with the
|
|
24
|
+
* zod issues in `data`, or unparsable JSON), so the route can still answer the RFC 6749 `invalid_request` body.
|
|
25
|
+
*/
|
|
26
|
+
export declare function invalidRequestDescription(error: unknown): string;
|
|
@@ -2,6 +2,11 @@ import { scopes as apiScopes } from "#auth/scopes";
|
|
|
2
2
|
export function useOAuthConfig() {
|
|
3
3
|
return useRuntimeConfig().oauthServer;
|
|
4
4
|
}
|
|
5
|
+
export function getOAuthCorsOrigins() {
|
|
6
|
+
const configured = useOAuthConfig().corsOrigins;
|
|
7
|
+
const list = Array.isArray(configured) ? configured : String(configured ?? "").split(",");
|
|
8
|
+
return list.map((origin) => origin.trim()).filter((origin) => origin && origin !== "*");
|
|
9
|
+
}
|
|
5
10
|
export function getOAuthIssuer(event) {
|
|
6
11
|
const configured = useOAuthConfig().issuer;
|
|
7
12
|
const issuer = configured || getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin;
|
|
@@ -19,3 +24,8 @@ export function oauthError(event, statusCode, error, description) {
|
|
|
19
24
|
setResponseHeader(event, "Cache-Control", "no-store");
|
|
20
25
|
return { error, ...description ? { error_description: description } : {} };
|
|
21
26
|
}
|
|
27
|
+
export function invalidRequestDescription(error) {
|
|
28
|
+
const issues = error?.data?.issues;
|
|
29
|
+
if (!Array.isArray(issues) || !issues.length) return "The request body is malformed.";
|
|
30
|
+
return `Invalid request: ${issues.map((issue) => [issue.path?.map(String).join("."), issue.message].filter(Boolean).join(" ")).join(", ")}`;
|
|
31
|
+
}
|
|
@@ -8,9 +8,16 @@ export declare function safeEqual(a: string, b: string): boolean;
|
|
|
8
8
|
export declare function computeCodeChallenge(verifier: string): Promise<string>;
|
|
9
9
|
export declare function verifyCodeChallenge(verifier: string, challenge: string): Promise<boolean>;
|
|
10
10
|
/**
|
|
11
|
-
* Client
|
|
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;
|