@frigear-nu/oauth-server 0.0.0-stage → 0.0.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/README.md +49 -2
- package/dist/module.d.mts +206 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +107 -0
- package/dist/runtime/app/composables/index.d.ts +1 -0
- package/dist/runtime/app/composables/index.js +1 -0
- package/dist/runtime/app/composables/useOAuthApplications.d.ts +19 -0
- package/dist/runtime/app/composables/useOAuthApplications.js +41 -0
- package/dist/runtime/app/pages/admin.d.vue.ts +3 -0
- package/dist/runtime/app/pages/admin.vue +384 -0
- package/dist/runtime/app/pages/admin.vue.d.ts +3 -0
- package/dist/runtime/app/pages/applications.d.vue.ts +3 -0
- package/dist/runtime/app/pages/applications.vue +50 -0
- package/dist/runtime/app/pages/applications.vue.d.ts +3 -0
- package/dist/runtime/app/pages/authorize.d.vue.ts +3 -0
- package/dist/runtime/app/pages/authorize.vue +108 -0
- package/dist/runtime/app/pages/authorize.vue.d.ts +3 -0
- package/dist/runtime/menus.d.ts +2 -0
- package/dist/runtime/menus.js +27 -0
- package/dist/runtime/server/db/schema.sqlite.d.ts +682 -0
- package/dist/runtime/server/db/schema.sqlite.js +60 -0
- package/dist/runtime/server/routes/api/admin/application-revoke.post.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/application-revoke.post.js +7 -0
- package/dist/runtime/server/routes/api/admin/application-secret.post.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/application-secret.post.js +5 -0
- package/dist/runtime/server/routes/api/admin/application.delete.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/application.delete.js +6 -0
- package/dist/runtime/server/routes/api/admin/application.get.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/application.get.js +6 -0
- package/dist/runtime/server/routes/api/admin/application.patch.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/application.patch.js +9 -0
- package/dist/runtime/server/routes/api/admin/applications.get.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/applications.get.js +11 -0
- package/dist/runtime/server/routes/api/admin/applications.post.d.ts +2 -0
- package/dist/runtime/server/routes/api/admin/applications.post.js +8 -0
- package/dist/runtime/server/routes/api/consent.get.d.ts +2 -0
- package/dist/runtime/server/routes/api/consent.get.js +19 -0
- package/dist/runtime/server/routes/api/me-applications.get.d.ts +2 -0
- package/dist/runtime/server/routes/api/me-applications.get.js +4 -0
- package/dist/runtime/server/routes/oidc/authorize.get.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/authorize.get.js +15 -0
- package/dist/runtime/server/routes/oidc/authorize.post.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/authorize.post.js +27 -0
- package/dist/runtime/server/routes/oidc/jwks.get.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/jwks.get.js +4 -0
- package/dist/runtime/server/routes/oidc/openid-configuration.get.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/openid-configuration.get.js +21 -0
- package/dist/runtime/server/routes/oidc/revoke.post.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/revoke.post.js +13 -0
- package/dist/runtime/server/routes/oidc/token.post.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/token.post.js +56 -0
- package/dist/runtime/server/routes/oidc/userinfo.get.d.ts +2 -0
- package/dist/runtime/server/routes/oidc/userinfo.get.js +18 -0
- package/dist/runtime/server/utils/oauth-access.d.ts +34 -0
- package/dist/runtime/server/utils/oauth-access.js +49 -0
- package/dist/runtime/server/utils/oauth-applications.d.ts +65 -0
- package/dist/runtime/server/utils/oauth-applications.js +126 -0
- package/dist/runtime/server/utils/oauth-authorize.d.ts +29 -0
- package/dist/runtime/server/utils/oauth-authorize.js +33 -0
- package/dist/runtime/server/utils/oauth-config.d.ts +16 -0
- package/dist/runtime/server/utils/oauth-config.js +20 -0
- package/dist/runtime/server/utils/oauth-crypto.d.ts +18 -0
- package/dist/runtime/server/utils/oauth-crypto.js +48 -0
- package/dist/runtime/server/utils/oauth-grants.d.ts +60 -0
- package/dist/runtime/server/utils/oauth-grants.js +70 -0
- package/dist/runtime/server/utils/oauth-keys.d.ts +41 -0
- package/dist/runtime/server/utils/oauth-keys.js +55 -0
- package/dist/runtime/server/utils/oauth-tokens.d.ts +25 -0
- package/dist/runtime/server/utils/oauth-tokens.js +34 -0
- package/dist/runtime/utils/index.d.ts +24 -0
- package/dist/runtime/utils/index.js +51 -0
- package/dist/types.d.mts +3 -0
- package/package.json +92 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
|
2
|
+
export const oauthApplications = sqliteTable("oauth_applications", {
|
|
3
|
+
/** `app_` + random id; it is the OAuth `client_id`. */
|
|
4
|
+
id: text().primaryKey(),
|
|
5
|
+
/** SHA-256 hex of the client secret. The secret itself is only shown once. */
|
|
6
|
+
secretHash: text().notNull(),
|
|
7
|
+
name: text().notNull(),
|
|
8
|
+
description: text(),
|
|
9
|
+
icon: text(),
|
|
10
|
+
priority: integer().notNull().default(0),
|
|
11
|
+
/** Origin of the application, no path. The redirect URI is derived from it. */
|
|
12
|
+
websiteUrl: text().notNull(),
|
|
13
|
+
/** Where "open application" links go; defaults to the website URL. */
|
|
14
|
+
loginUrl: text(),
|
|
15
|
+
/** e.g. `https://*.vercel.app`: preview deployments that may use the callback too. */
|
|
16
|
+
previewUrlPattern: text(),
|
|
17
|
+
/** Extra exact redirect URIs. */
|
|
18
|
+
redirectUris: text({ mode: "json" }).$type().notNull().$defaultFn(() => []),
|
|
19
|
+
/** Roles allowed to sign in. Empty = every signed-in user. Roles are resolved by the host (`oauth:user:roles`). */
|
|
20
|
+
allowedRoles: text({ mode: "json" }).$type().notNull().$defaultFn(() => []),
|
|
21
|
+
tags: text({ mode: "json" }).$type().notNull().$defaultFn(() => []),
|
|
22
|
+
ownerId: integer(),
|
|
23
|
+
isActive: integer({ mode: "boolean" }).notNull().default(true),
|
|
24
|
+
createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
25
|
+
updatedAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()).$onUpdateFn(() => /* @__PURE__ */ new Date())
|
|
26
|
+
}, (t) => [
|
|
27
|
+
index("oauth_application_active_idx").on(t.isActive, t.priority)
|
|
28
|
+
]);
|
|
29
|
+
export const oauthAuthorizationCodes = sqliteTable("oauth_authorization_codes", {
|
|
30
|
+
codeHash: text().primaryKey(),
|
|
31
|
+
applicationId: text().notNull().references(() => oauthApplications.id, { onDelete: "cascade" }),
|
|
32
|
+
userId: integer().notNull(),
|
|
33
|
+
redirectUri: text().notNull(),
|
|
34
|
+
scope: text().notNull(),
|
|
35
|
+
codeChallenge: text().notNull(),
|
|
36
|
+
nonce: text(),
|
|
37
|
+
/** When the user signed in / approved; becomes `auth_time` in the ID token. */
|
|
38
|
+
authTime: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
39
|
+
expiresAt: integer({ mode: "timestamp" }).notNull()
|
|
40
|
+
}, (t) => [
|
|
41
|
+
index("oauth_code_application_idx").on(t.applicationId)
|
|
42
|
+
]);
|
|
43
|
+
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
|
44
|
+
id: text().primaryKey(),
|
|
45
|
+
tokenHash: text().notNull().unique(),
|
|
46
|
+
applicationId: text().notNull().references(() => oauthApplications.id, { onDelete: "cascade" }),
|
|
47
|
+
userId: integer().notNull(),
|
|
48
|
+
scope: text().notNull(),
|
|
49
|
+
/** Shared by every token of one authorization, so a reuse can revoke them all. */
|
|
50
|
+
familyId: text().notNull(),
|
|
51
|
+
/** Id of the token that replaced this one when it was rotated. */
|
|
52
|
+
replacedBy: text(),
|
|
53
|
+
authTime: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date()),
|
|
54
|
+
expiresAt: integer({ mode: "timestamp" }).notNull(),
|
|
55
|
+
revokedAt: integer({ mode: "timestamp" }),
|
|
56
|
+
createdAt: integer({ mode: "timestamp" }).notNull().$defaultFn(() => /* @__PURE__ */ new Date())
|
|
57
|
+
}, (t) => [
|
|
58
|
+
index("oauth_refresh_user_idx").on(t.userId, t.applicationId),
|
|
59
|
+
index("oauth_refresh_family_idx").on(t.familyId)
|
|
60
|
+
]);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
await requireOAuthAdmin(event);
|
|
3
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
4
|
+
if (!await getApplication(id)) throw createError({ statusCode: 404, statusMessage: "Application not found." });
|
|
5
|
+
await revokeApplicationGrants(id);
|
|
6
|
+
return { revoked: true };
|
|
7
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
await requireOAuthAdmin(event);
|
|
3
|
+
const application = await getApplication(getRouterParam(event, "id") ?? "");
|
|
4
|
+
if (!application) throw createError({ statusCode: 404, statusMessage: "Application not found." });
|
|
5
|
+
return { ...application, callbackUrl: `${application.websiteUrl}${useOAuthConfig().callbackPath}` };
|
|
6
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { applicationPatchSchema } from "../../../utils/oauth-applications.js";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
await requireOAuthAdmin(event);
|
|
4
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
5
|
+
const patch = await readValidatedBody(event, applicationPatchSchema.parse);
|
|
6
|
+
const application = await updateApplication(id, patch);
|
|
7
|
+
if (patch.isActive === false) await revokeApplicationGrants(id);
|
|
8
|
+
return application;
|
|
9
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
await requireOAuthAdmin(event);
|
|
3
|
+
const applications = await listApplications();
|
|
4
|
+
const grants = await countActiveGrants(applications.map((application) => application.id));
|
|
5
|
+
return applications.map((application) => ({
|
|
6
|
+
...application,
|
|
7
|
+
activeGrants: grants[application.id] ?? 0,
|
|
8
|
+
// The redirect URI applications have to register on their side.
|
|
9
|
+
callbackUrl: `${application.websiteUrl}${useOAuthConfig().callbackPath}`
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { applicationInputSchema } from "../../../utils/oauth-applications.js";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
const { userId } = await requireOAuthAdmin(event);
|
|
4
|
+
const input = await readValidatedBody(event, applicationInputSchema.parse);
|
|
5
|
+
const { application, secret } = await createApplication(input, userId);
|
|
6
|
+
setResponseStatus(event, 201);
|
|
7
|
+
return { ...application, clientId: application.id, clientSecret: secret };
|
|
8
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getQuery } from "h3";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
const userId = await requireSessionUserId(event);
|
|
4
|
+
const { application, scope } = await validateAuthorizeRequest(getQuery(event));
|
|
5
|
+
const user = await getOAuthUser(userId);
|
|
6
|
+
if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
|
|
7
|
+
await assertApplicationAccess(event, user, application);
|
|
8
|
+
return {
|
|
9
|
+
application: {
|
|
10
|
+
id: application.id,
|
|
11
|
+
name: application.name,
|
|
12
|
+
description: application.description,
|
|
13
|
+
icon: application.icon,
|
|
14
|
+
websiteUrl: application.websiteUrl
|
|
15
|
+
},
|
|
16
|
+
scope,
|
|
17
|
+
user: { name: user.name, email: user.email, avatar: user.avatar }
|
|
18
|
+
};
|
|
19
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
const query = getQuery(event);
|
|
4
|
+
const { application } = await validateAuthorizeRequest(query);
|
|
5
|
+
const session = await getUserSession(event);
|
|
6
|
+
if (!session.user) {
|
|
7
|
+
const url2 = getRequestURL(event);
|
|
8
|
+
return sendRedirect(event, `${getSignInPath()}?redirect=${encodeURIComponent(`${url2.pathname}${url2.search}`)}`, 302);
|
|
9
|
+
}
|
|
10
|
+
const user = await getOAuthUser(Number(session.user.id));
|
|
11
|
+
if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
|
|
12
|
+
await assertApplicationAccess(event, user, application);
|
|
13
|
+
const url = getRequestURL(event);
|
|
14
|
+
return sendRedirect(event, `${useOAuthConfig().authorizePath}${url.search}`, 302);
|
|
15
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { authorizeRequestSchema } from "../../utils/oauth-authorize.js";
|
|
3
|
+
const bodySchema = authorizeRequestSchema.extend({ approved: z.boolean() });
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const userId = await requireSessionUserId(event);
|
|
6
|
+
const { approved, ...params } = await readValidatedBody(event, bodySchema.parse);
|
|
7
|
+
const { request, application, scope } = await validateAuthorizeRequest(params);
|
|
8
|
+
const redirect = new URL(request.redirect_uri);
|
|
9
|
+
redirect.searchParams.set("state", request.state);
|
|
10
|
+
if (!approved) {
|
|
11
|
+
redirect.searchParams.set("error", "access_denied");
|
|
12
|
+
return { redirectUrl: redirect.toString() };
|
|
13
|
+
}
|
|
14
|
+
const user = await getOAuthUser(userId);
|
|
15
|
+
if (!user) throw createError({ statusCode: 401, statusMessage: "Unknown user." });
|
|
16
|
+
await assertApplicationAccess(event, user, application);
|
|
17
|
+
const code = await createAuthorizationCode({
|
|
18
|
+
application,
|
|
19
|
+
userId,
|
|
20
|
+
redirectUri: request.redirect_uri,
|
|
21
|
+
scope,
|
|
22
|
+
codeChallenge: request.code_challenge,
|
|
23
|
+
nonce: request.nonce
|
|
24
|
+
});
|
|
25
|
+
redirect.searchParams.set("code", code);
|
|
26
|
+
return { redirectUrl: redirect.toString() };
|
|
27
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
const issuer = getOAuthIssuer(event);
|
|
3
|
+
setResponseHeader(event, "Cache-Control", "public, max-age=3600");
|
|
4
|
+
return {
|
|
5
|
+
issuer,
|
|
6
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
7
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
8
|
+
userinfo_endpoint: `${issuer}/oauth/userinfo`,
|
|
9
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
10
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
11
|
+
response_types_supported: ["code"],
|
|
12
|
+
response_modes_supported: ["query"],
|
|
13
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
14
|
+
subject_types_supported: ["public"],
|
|
15
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
16
|
+
scopes_supported: getSupportedScopes(),
|
|
17
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
18
|
+
code_challenge_methods_supported: ["S256"],
|
|
19
|
+
claims_supported: ["sub", "iss", "aud", "exp", "iat", "auth_time", "nonce", "name", "picture", "email"]
|
|
20
|
+
};
|
|
21
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readBody } from "h3";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
const body = await readBody(event).catch(() => void 0);
|
|
4
|
+
const credentials = readClientCredentials(event, body);
|
|
5
|
+
const row = credentials ? await getApplicationRow(credentials.clientId) : null;
|
|
6
|
+
if (!credentials || !row || !await verifyApplicationSecret(row, credentials.clientSecret)) {
|
|
7
|
+
if (credentials?.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
|
|
8
|
+
return oauthError(event, 401, "invalid_client", "Client authentication failed.");
|
|
9
|
+
}
|
|
10
|
+
if (typeof body?.token === "string") await revokeRefreshToken(body.token, row.id);
|
|
11
|
+
setResponseStatus(event, 200);
|
|
12
|
+
return {};
|
|
13
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readBody } from "h3";
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
4
|
+
setResponseHeader(event, "Pragma", "no-cache");
|
|
5
|
+
const body = await readBody(event).catch(() => void 0);
|
|
6
|
+
const credentials = readClientCredentials(event, body);
|
|
7
|
+
const row = credentials ? await getApplicationRow(credentials.clientId) : null;
|
|
8
|
+
if (!credentials || !row || !await verifyApplicationSecret(row, credentials.clientSecret)) {
|
|
9
|
+
if (credentials?.basic) setResponseHeader(event, "WWW-Authenticate", "Basic");
|
|
10
|
+
return oauthError(event, 401, "invalid_client", "Client authentication failed.");
|
|
11
|
+
}
|
|
12
|
+
const application = toApplication(row);
|
|
13
|
+
const grantType = body?.grant_type;
|
|
14
|
+
let grant;
|
|
15
|
+
let refreshToken;
|
|
16
|
+
if (grantType === "authorization_code") {
|
|
17
|
+
const { code, redirect_uri: redirectUri, code_verifier: codeVerifier } = body;
|
|
18
|
+
if (typeof code !== "string" || typeof redirectUri !== "string" || typeof codeVerifier !== "string") {
|
|
19
|
+
return oauthError(event, 400, "invalid_request", "code, redirect_uri and code_verifier are required.");
|
|
20
|
+
}
|
|
21
|
+
const result = await exchangeAuthorizationCode({ code, application, redirectUri, codeVerifier });
|
|
22
|
+
if ("error" in result) return oauthError(event, 400, "invalid_grant", result.error);
|
|
23
|
+
grant = result.grant;
|
|
24
|
+
refreshToken = (await createRefreshToken({ applicationId: application.id, ...grant })).token;
|
|
25
|
+
} else if (grantType === "refresh_token") {
|
|
26
|
+
if (typeof body?.refresh_token !== "string") return oauthError(event, 400, "invalid_request", "refresh_token is required.");
|
|
27
|
+
const result = await rotateRefreshToken({ token: body.refresh_token, application });
|
|
28
|
+
if ("error" in result) return oauthError(event, 400, "invalid_grant", result.error);
|
|
29
|
+
grant = result.grant;
|
|
30
|
+
refreshToken = result.token;
|
|
31
|
+
} else {
|
|
32
|
+
return oauthError(event, 400, "unsupported_grant_type", "Supported grants: authorization_code, refresh_token.");
|
|
33
|
+
}
|
|
34
|
+
const user = await getOAuthUser(grant.userId);
|
|
35
|
+
if (!user) return oauthError(event, 400, "invalid_grant", "The user no longer exists.");
|
|
36
|
+
const access = await checkApplicationAccess(user, application, event);
|
|
37
|
+
if (!access.allowed) return oauthError(event, 400, "access_denied", access.reason);
|
|
38
|
+
const input = { event, user, application, scope: grant.scope, roles: access.roles };
|
|
39
|
+
const response = {
|
|
40
|
+
access_token: await signAccessToken(input),
|
|
41
|
+
token_type: "Bearer",
|
|
42
|
+
expires_in: useOAuthConfig().accessTokenTtl,
|
|
43
|
+
refresh_token: refreshToken,
|
|
44
|
+
scope: grant.scope.join(" ")
|
|
45
|
+
};
|
|
46
|
+
if (grant.scope.includes("openid")) {
|
|
47
|
+
response.id_token = await signIdToken({ ...input, nonce: grant.nonce, authTime: grant.authTime });
|
|
48
|
+
}
|
|
49
|
+
await useNitroApp().hooks.callHook("oauth:token:issued", {
|
|
50
|
+
userId: user.id,
|
|
51
|
+
applicationId: application.id,
|
|
52
|
+
scope: grant.scope,
|
|
53
|
+
grant: grantType
|
|
54
|
+
});
|
|
55
|
+
return response;
|
|
56
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
setResponseHeader(event, "Cache-Control", "no-store");
|
|
3
|
+
const header = getHeader(event, "authorization");
|
|
4
|
+
const token = header?.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
5
|
+
const payload = token ? await verifyAccessToken(event, token) : null;
|
|
6
|
+
if (!payload) {
|
|
7
|
+
setResponseHeader(event, "WWW-Authenticate", 'Bearer error="invalid_token"');
|
|
8
|
+
return oauthError(event, 401, "invalid_token", "The access token is missing, invalid or expired.");
|
|
9
|
+
}
|
|
10
|
+
const application = await getApplication(payload.client_id);
|
|
11
|
+
const user = await getOAuthUser(Number(payload.sub));
|
|
12
|
+
if (!application || !user) return oauthError(event, 401, "invalid_token", "The access token is no longer valid.");
|
|
13
|
+
const access = await checkApplicationAccess(user, application, event);
|
|
14
|
+
if (!access.allowed) return oauthError(event, 403, "access_denied", access.reason);
|
|
15
|
+
const scope = payload.scope.split(" ").filter(Boolean);
|
|
16
|
+
const claims = await buildClaims({ user, application, scope, roles: access.roles, tokenType: "userinfo" });
|
|
17
|
+
return { sub: String(user.id), ...claims };
|
|
18
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { H3Event } from 'h3';
|
|
2
|
+
import type { OAuthApplication, OAuthUser, TokenType } from '../../../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Gate for the admin routes. This repo has no shared permission system, so the host decides through
|
|
5
|
+
* the `oauth:authorize` Nitro hook. Denied by default:
|
|
6
|
+
*
|
|
7
|
+
* nitroApp.hooks.hook('oauth:authorize', (ctx) => { ctx.allowed = isStaff(ctx.userId) })
|
|
8
|
+
*/
|
|
9
|
+
export declare function requireOAuthAdmin(event: H3Event): Promise<{
|
|
10
|
+
userId: number;
|
|
11
|
+
}>;
|
|
12
|
+
/** The user's roles as reported by the `oauth:user:roles` hook listeners (none without a listener). */
|
|
13
|
+
export declare function resolveUserRoles(userId: number, event?: H3Event): Promise<string[]>;
|
|
14
|
+
/** The user row without the password hash; includes columns the host added to `users`. */
|
|
15
|
+
export declare function getOAuthUser(userId: number): Promise<OAuthUser | null>;
|
|
16
|
+
export type ApplicationAccess = {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
roles: string[];
|
|
20
|
+
};
|
|
21
|
+
/** Active, role allowed and not vetoed through `oauth:application:access`. */
|
|
22
|
+
export declare function checkApplicationAccess(user: OAuthUser, application: OAuthApplication, event?: H3Event): Promise<ApplicationAccess>;
|
|
23
|
+
export declare function userCanAccessApplication(userId: number, application: OAuthApplication, event?: H3Event): Promise<boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Builds the claims for a token: standard profile claims by scope, then the `oauth:claims` hook so the
|
|
26
|
+
* host can add or change anything. Registered claims are re-applied by the signer afterwards.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildClaims(input: {
|
|
29
|
+
user: OAuthUser;
|
|
30
|
+
application: OAuthApplication;
|
|
31
|
+
scope: string[];
|
|
32
|
+
roles: string[];
|
|
33
|
+
tokenType: TokenType;
|
|
34
|
+
}): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { db, schema } from "@nuxthub/db";
|
|
3
|
+
import { matchesAllowedRoles } from "../../utils/index.js";
|
|
4
|
+
export async function requireOAuthAdmin(event) {
|
|
5
|
+
const userId = await requireSessionUserId(event);
|
|
6
|
+
const ctx = { event, userId, allowed: false };
|
|
7
|
+
await useNitroApp().hooks.callHook("oauth:authorize", ctx);
|
|
8
|
+
if (!ctx.allowed) throw createError({ statusCode: 403, statusMessage: "You are not allowed to manage OAuth applications." });
|
|
9
|
+
return { userId };
|
|
10
|
+
}
|
|
11
|
+
export async function resolveUserRoles(userId, event) {
|
|
12
|
+
const ctx = { event, userId, roles: [] };
|
|
13
|
+
await useNitroApp().hooks.callHook("oauth:user:roles", ctx);
|
|
14
|
+
return [...new Set(ctx.roles)];
|
|
15
|
+
}
|
|
16
|
+
export async function getOAuthUser(userId) {
|
|
17
|
+
const [row] = await db.select().from(schema.users).where(eq(schema.users.id, userId)).limit(1);
|
|
18
|
+
if (!row) return null;
|
|
19
|
+
const { password: _password, ...user } = row;
|
|
20
|
+
return user;
|
|
21
|
+
}
|
|
22
|
+
export async function checkApplicationAccess(user, application, event) {
|
|
23
|
+
const roles = await resolveUserRoles(user.id, event);
|
|
24
|
+
if (!application.isActive) return { allowed: false, reason: "This application is disabled.", roles };
|
|
25
|
+
if (!matchesAllowedRoles(application.allowedRoles, roles)) {
|
|
26
|
+
return { allowed: false, reason: "You do not have permission to access this application.", roles };
|
|
27
|
+
}
|
|
28
|
+
const ctx = { event, user, application, roles, allowed: true };
|
|
29
|
+
await useNitroApp().hooks.callHook("oauth:application:access", ctx);
|
|
30
|
+
return ctx.allowed ? { allowed: true, roles } : { allowed: false, reason: ctx.reason || "Access to this application was denied.", roles };
|
|
31
|
+
}
|
|
32
|
+
export async function userCanAccessApplication(userId, application, event) {
|
|
33
|
+
const user = await getOAuthUser(userId);
|
|
34
|
+
return user ? (await checkApplicationAccess(user, application, event)).allowed : false;
|
|
35
|
+
}
|
|
36
|
+
const RESERVED_CLAIMS = ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "nonce", "auth_time", "scope", "client_id"];
|
|
37
|
+
export async function buildClaims(input) {
|
|
38
|
+
const claims = {};
|
|
39
|
+
if (input.tokenType !== "access_token") {
|
|
40
|
+
if (input.scope.includes("profile")) {
|
|
41
|
+
claims.name = input.user.name;
|
|
42
|
+
if (input.user.avatar) claims.picture = input.user.avatar;
|
|
43
|
+
}
|
|
44
|
+
if (input.scope.includes("email")) claims.email = input.user.email;
|
|
45
|
+
}
|
|
46
|
+
const ctx = { ...input, claims };
|
|
47
|
+
await useNitroApp().hooks.callHook("oauth:claims", ctx);
|
|
48
|
+
return Object.fromEntries(Object.entries(ctx.claims).filter(([name]) => !RESERVED_CLAIMS.includes(name)));
|
|
49
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { schema } from '@nuxthub/db';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { H3Event } from 'h3';
|
|
4
|
+
import type { ApplicationInput, OAuthApplication, UserApplicationDto } from '../../../types.js';
|
|
5
|
+
type ApplicationRow = typeof schema.oauthApplications.$inferSelect;
|
|
6
|
+
/** Validation shared by the admin API and `createApplication` / `updateApplication`. */
|
|
7
|
+
export declare const applicationInputSchema: z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
websiteUrl: z.ZodString;
|
|
10
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
12
|
+
priority: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
loginUrl: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
14
|
+
previewUrlPattern: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
15
|
+
redirectUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
16
|
+
allowedRoles: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
|
+
isActive: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
}, z.core.$strip>;
|
|
20
|
+
export declare const applicationPatchSchema: z.ZodObject<{
|
|
21
|
+
name: z.ZodOptional<z.ZodString>;
|
|
22
|
+
websiteUrl: z.ZodOptional<z.ZodString>;
|
|
23
|
+
description: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
24
|
+
icon: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
25
|
+
priority: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
|
|
26
|
+
loginUrl: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
27
|
+
previewUrlPattern: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
|
28
|
+
redirectUris: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
|
|
29
|
+
allowedRoles: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
|
|
30
|
+
tags: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodString>>>;
|
|
31
|
+
isActive: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
|
|
32
|
+
}, z.core.$strip>;
|
|
33
|
+
export declare function toApplication(row: ApplicationRow): OAuthApplication;
|
|
34
|
+
/** The row including `secretHash`; only for credential checks. Use `getApplication` everywhere else. */
|
|
35
|
+
export declare function getApplicationRow(id: string): Promise<ApplicationRow | null>;
|
|
36
|
+
export declare function getApplication(id: string): Promise<OAuthApplication | null>;
|
|
37
|
+
export declare function listApplications(): Promise<OAuthApplication[]>;
|
|
38
|
+
/** Creates an application. The returned `secret` is the only time the plain secret is available. */
|
|
39
|
+
export declare function createApplication(input: ApplicationInput, ownerId?: number | null): Promise<{
|
|
40
|
+
application: OAuthApplication;
|
|
41
|
+
secret: string;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function updateApplication(id: string, patch: Partial<ApplicationInput>): Promise<OAuthApplication>;
|
|
44
|
+
/** Deletes the application with its authorization codes and refresh tokens. */
|
|
45
|
+
export declare function deleteApplication(id: string): Promise<void>;
|
|
46
|
+
/** Replaces the client secret and revokes the refresh tokens issued with the old one. */
|
|
47
|
+
export declare function rotateApplicationSecret(id: string): Promise<{
|
|
48
|
+
secret: string;
|
|
49
|
+
}>;
|
|
50
|
+
export declare function verifyApplicationSecret(row: ApplicationRow, secret: string): Promise<boolean>;
|
|
51
|
+
/** Revokes every refresh token of an application (e.g. after a secret leak). */
|
|
52
|
+
export declare function revokeApplicationGrants(applicationId: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Revokes a user's refresh tokens, for one application or all of them. Call it when a user is
|
|
55
|
+
* deactivated or loses a role; already issued access tokens stay valid until they expire.
|
|
56
|
+
*/
|
|
57
|
+
export declare function revokeUserGrants(userId: number, applicationId?: string): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* The active applications a user may sign in to (role check through `oauth:user:roles`), highest
|
|
60
|
+
* priority first. Applications with a `loginUrl` link there, the others to their website.
|
|
61
|
+
*/
|
|
62
|
+
export declare function listUserApplications(userId: number, event?: H3Event): Promise<UserApplicationDto[]>;
|
|
63
|
+
/** Counts the refresh tokens still valid per application (admin overview). */
|
|
64
|
+
export declare function countActiveGrants(applicationIds: string[]): Promise<Record<string, number>>;
|
|
65
|
+
export {};
|