@lobb-js/lobb-ext-auth 0.13.0 → 0.14.1
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/auth.js +3 -3
- package/dist/index.js +10 -10
- package/extensions/auth/database/init.ts +3 -3
- package/extensions/auth/index.ts +2 -0
- package/extensions/auth/openapi.ts +3 -3
- package/extensions/auth/studio/auth.ts +3 -3
- package/extensions/auth/studio/index.ts +10 -10
- package/extensions/auth/workflows/actionController.ts +35 -0
- package/extensions/auth/workflows/actions.ts +89 -0
- package/extensions/auth/workflows/baseWorkflow.ts +52 -162
- package/extensions/auth/workflows/currentUserPermissionsWorkflow.ts +12 -11
- package/extensions/auth/workflows/index.ts +2 -0
- package/extensions/auth/workflows/meAliasWorkflows.ts +16 -12
- package/extensions/auth/workflows/policiesWorkflows.ts +0 -8
- package/extensions/auth/workflows/sharesWorkflows.ts +60 -60
- package/package.json +3 -3
package/dist/auth.js
CHANGED
|
@@ -17,7 +17,7 @@ export class Auth {
|
|
|
17
17
|
async login(payload) {
|
|
18
18
|
const response = await this.utils.lobb.request({
|
|
19
19
|
method: "POST",
|
|
20
|
-
route: "/api/
|
|
20
|
+
route: "/api/actions/auth_login",
|
|
21
21
|
payload: {
|
|
22
22
|
data: payload,
|
|
23
23
|
},
|
|
@@ -41,8 +41,8 @@ export class Auth {
|
|
|
41
41
|
}
|
|
42
42
|
async logout() {
|
|
43
43
|
await this.utils.lobb.request({
|
|
44
|
-
method: "
|
|
45
|
-
route: "/api/
|
|
44
|
+
method: "POST",
|
|
45
|
+
route: "/api/actions/auth_logout",
|
|
46
46
|
});
|
|
47
47
|
localStorage.removeItem("lobb_session");
|
|
48
48
|
const currentPath = window.location.pathname;
|
package/dist/index.js
CHANGED
|
@@ -19,36 +19,36 @@ export default function extension(utils) {
|
|
|
19
19
|
{
|
|
20
20
|
// Generic auth primitive: callers ask "can the current user access X?"
|
|
21
21
|
// and pass any combination of { collection, action, role }. The handler
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// takes over with a boolean (`ctx.takeOver(bool)`); callers read that
|
|
23
|
+
// and decide what to render. A take-over is required because the
|
|
24
|
+
// dispatch ignores plain returns — a bare `return false` would be
|
|
25
|
+
// dropped and the caller would default to allow.
|
|
26
26
|
eventName: "auth.canAccess",
|
|
27
|
-
handler: async (input) => {
|
|
27
|
+
handler: async (input, ctx) => {
|
|
28
28
|
// Admin shortcut — admins bypass everything, regardless of how
|
|
29
29
|
// their permissions snapshot was populated.
|
|
30
30
|
const user = utils.ctx.extensions.auth?.user;
|
|
31
31
|
if (user?.role === "admin")
|
|
32
|
-
return true;
|
|
32
|
+
return ctx.takeOver(true);
|
|
33
33
|
// Walk the permissions snapshot. Works for both logged-in non-admin
|
|
34
34
|
// users (snapshot from their role) and share-token recipients
|
|
35
35
|
// (snapshot from the share's embedded permissions). If nothing
|
|
36
36
|
// is populated — anonymous viewer — default to deny.
|
|
37
37
|
const permissions = utils.ctx.extensions.auth?.permissions;
|
|
38
38
|
if (permissions === undefined || permissions === null)
|
|
39
|
-
return false;
|
|
39
|
+
return ctx.takeOver(false);
|
|
40
40
|
if (permissions === true)
|
|
41
|
-
return true;
|
|
41
|
+
return ctx.takeOver(true);
|
|
42
42
|
const action = input.action ?? "read";
|
|
43
43
|
const requiredCollections = Array.isArray(input.collection)
|
|
44
44
|
? input.collection
|
|
45
45
|
: input.collection ? [input.collection] : [];
|
|
46
46
|
for (const collectionName of requiredCollections) {
|
|
47
47
|
if (!isActionAllowed(collectionName, action, permissions)) {
|
|
48
|
-
return false;
|
|
48
|
+
return ctx.takeOver(false);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
return true;
|
|
51
|
+
return ctx.takeOver(true);
|
|
52
52
|
},
|
|
53
53
|
},
|
|
54
54
|
],
|
|
@@ -12,7 +12,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
|
|
|
12
12
|
// syncinc the admin user in users collection
|
|
13
13
|
const config = extensionConfig;
|
|
14
14
|
const { email, password, ...extraFields } = config.admin;
|
|
15
|
-
const entries = (await lobb.
|
|
15
|
+
const entries = (await lobb.collectionStore.findAll({
|
|
16
16
|
collectionName: "auth_users",
|
|
17
17
|
params: {
|
|
18
18
|
filter: {
|
|
@@ -23,7 +23,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
|
|
|
23
23
|
})).data;
|
|
24
24
|
const adminUser = entries[0];
|
|
25
25
|
if (!adminUser) {
|
|
26
|
-
await lobb.
|
|
26
|
+
await lobb.collectionStore.createOne({
|
|
27
27
|
collectionName: "auth_users",
|
|
28
28
|
data: {
|
|
29
29
|
...extraFields,
|
|
@@ -39,7 +39,7 @@ async function syncincAdminUserInDB(lobb: Lobb, extensionConfig: ExtensionConfig
|
|
|
39
39
|
);
|
|
40
40
|
|
|
41
41
|
if (adminUser.email !== email || !passwordIdentical) {
|
|
42
|
-
await lobb.
|
|
42
|
+
await lobb.collectionStore.updateOne({
|
|
43
43
|
collectionName: "auth_users",
|
|
44
44
|
id: adminUser.id,
|
|
45
45
|
data: {
|
package/extensions/auth/index.ts
CHANGED
|
@@ -5,12 +5,14 @@ import { collections } from "./collections/collections.ts";
|
|
|
5
5
|
import { meta } from "./meta/meta.ts";
|
|
6
6
|
import { migrations } from "./database/migrations.ts";
|
|
7
7
|
import { getWorkflows } from "./workflows/index.ts";
|
|
8
|
+
import { getAuthActions } from "./workflows/actions.ts";
|
|
8
9
|
|
|
9
10
|
export default function auth(extensionConfig: ExtensionConfig): Extension {
|
|
10
11
|
return {
|
|
11
12
|
name: "auth",
|
|
12
13
|
icon: "Key",
|
|
13
14
|
collections: (lobb) => collections(lobb, extensionConfig),
|
|
15
|
+
actions: getAuthActions(extensionConfig),
|
|
14
16
|
migrations: migrations,
|
|
15
17
|
meta: (lobb) => meta(lobb, extensionConfig),
|
|
16
18
|
workflows: getWorkflows(extensionConfig),
|
|
@@ -35,7 +35,7 @@ export const openapi = {
|
|
|
35
35
|
},
|
|
36
36
|
},
|
|
37
37
|
paths: {
|
|
38
|
-
"/api/
|
|
38
|
+
"/api/actions/auth_login": {
|
|
39
39
|
post: {
|
|
40
40
|
tags: [
|
|
41
41
|
"auth",
|
|
@@ -223,8 +223,8 @@ export const openapi = {
|
|
|
223
223
|
},
|
|
224
224
|
},
|
|
225
225
|
},
|
|
226
|
-
"/api/
|
|
227
|
-
|
|
226
|
+
"/api/actions/auth_logout": {
|
|
227
|
+
post: {
|
|
228
228
|
tags: [
|
|
229
229
|
"auth",
|
|
230
230
|
],
|
|
@@ -26,7 +26,7 @@ export class Auth {
|
|
|
26
26
|
public async login(payload: LoginPayload) {
|
|
27
27
|
const response = await this.utils.lobb.request({
|
|
28
28
|
method: "POST",
|
|
29
|
-
route: "/api/
|
|
29
|
+
route: "/api/actions/auth_login",
|
|
30
30
|
payload: {
|
|
31
31
|
data: payload,
|
|
32
32
|
},
|
|
@@ -55,8 +55,8 @@ export class Auth {
|
|
|
55
55
|
|
|
56
56
|
public async logout() {
|
|
57
57
|
await this.utils.lobb.request({
|
|
58
|
-
method: "
|
|
59
|
-
route: "/api/
|
|
58
|
+
method: "POST",
|
|
59
|
+
route: "/api/actions/auth_logout",
|
|
60
60
|
});
|
|
61
61
|
localStorage.removeItem("lobb_session");
|
|
62
62
|
const currentPath = window.location.pathname;
|
|
@@ -22,24 +22,24 @@ export default function extension(utils: ExtensionUtils): Extension {
|
|
|
22
22
|
{
|
|
23
23
|
// Generic auth primitive: callers ask "can the current user access X?"
|
|
24
24
|
// and pass any combination of { collection, action, role }. The handler
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
25
|
+
// takes over with a boolean (`ctx.takeOver(bool)`); callers read that
|
|
26
|
+
// and decide what to render. A take-over is required because the
|
|
27
|
+
// dispatch ignores plain returns — a bare `return false` would be
|
|
28
|
+
// dropped and the caller would default to allow.
|
|
29
29
|
eventName: "auth.canAccess",
|
|
30
|
-
handler: async (input) => {
|
|
30
|
+
handler: async (input, ctx) => {
|
|
31
31
|
// Admin shortcut — admins bypass everything, regardless of how
|
|
32
32
|
// their permissions snapshot was populated.
|
|
33
33
|
const user = utils.ctx.extensions.auth?.user;
|
|
34
|
-
if (user?.role === "admin") return true;
|
|
34
|
+
if (user?.role === "admin") return ctx.takeOver(true);
|
|
35
35
|
|
|
36
36
|
// Walk the permissions snapshot. Works for both logged-in non-admin
|
|
37
37
|
// users (snapshot from their role) and share-token recipients
|
|
38
38
|
// (snapshot from the share's embedded permissions). If nothing
|
|
39
39
|
// is populated — anonymous viewer — default to deny.
|
|
40
40
|
const permissions = utils.ctx.extensions.auth?.permissions;
|
|
41
|
-
if (permissions === undefined || permissions === null) return false;
|
|
42
|
-
if (permissions === true) return true;
|
|
41
|
+
if (permissions === undefined || permissions === null) return ctx.takeOver(false);
|
|
42
|
+
if (permissions === true) return ctx.takeOver(true);
|
|
43
43
|
|
|
44
44
|
const action: CollectionPermissionActionsKeys = input.action ?? "read";
|
|
45
45
|
|
|
@@ -49,11 +49,11 @@ export default function extension(utils: ExtensionUtils): Extension {
|
|
|
49
49
|
|
|
50
50
|
for (const collectionName of requiredCollections) {
|
|
51
51
|
if (!isActionAllowed(collectionName, action, permissions)) {
|
|
52
|
-
return false;
|
|
52
|
+
return ctx.takeOver(false);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
return true;
|
|
56
|
+
return ctx.takeOver(true);
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Workflow } from "@lobb-js/core";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { getBearerToken } from "../utils.ts";
|
|
4
|
+
|
|
5
|
+
// `auth_logout` needs the session token, which arrives as a bearer header. Read
|
|
6
|
+
// it off the Hono context here and pass it to the action (keeping the action
|
|
7
|
+
// pure + internally callable with an explicit token), then return 204.
|
|
8
|
+
//
|
|
9
|
+
// `auth_login` needs no HTTP adaptation — its JSON body { email, password } maps
|
|
10
|
+
// straight to the default action flow.
|
|
11
|
+
|
|
12
|
+
export function getAuthActionControllerWorkflows(): Workflow[] {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
name: "authLogoutActionController",
|
|
16
|
+
eventName: "core.actions.controller.override",
|
|
17
|
+
handler: async (input, ctx) => {
|
|
18
|
+
if (input.name === "auth_logout") {
|
|
19
|
+
const context = input.context as Context;
|
|
20
|
+
const token = getBearerToken(context);
|
|
21
|
+
if (!token) {
|
|
22
|
+
throw new ctx.LobbError({
|
|
23
|
+
code: "BAD_REQUEST",
|
|
24
|
+
message:
|
|
25
|
+
"You should pass session's token through the bearer header",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await ctx.lobb.runAction("auth_logout", { token });
|
|
30
|
+
return ctx.takeOver(context.body(null, 204));
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ActionsConfig } from "@lobb-js/core";
|
|
2
|
+
import { LobbError } from "@lobb-js/core";
|
|
3
|
+
import type { ExtensionConfig, User } from "../config/extensionConfigSchema.ts";
|
|
4
|
+
import { generateRandomId } from "../utils.ts";
|
|
5
|
+
import { verify } from "argon2";
|
|
6
|
+
|
|
7
|
+
// Login and logout used to hijack the auth_sessions CRUD endpoints (create =
|
|
8
|
+
// login, deleteMany = logout), which made it impossible to manage session rows
|
|
9
|
+
// normally. They're now first-class actions; the auth_sessions collection is a
|
|
10
|
+
// plain CRUD collection again.
|
|
11
|
+
|
|
12
|
+
export function getAuthActions(_extensionConfig: ExtensionConfig): ActionsConfig {
|
|
13
|
+
return {
|
|
14
|
+
// POST /api/actions/auth_login { data: { email, password } }
|
|
15
|
+
auth_login: {
|
|
16
|
+
handler: async (input: any, ctx) => {
|
|
17
|
+
const email = input.data?.email ?? input.email;
|
|
18
|
+
const password = input.data?.password ?? input.password;
|
|
19
|
+
|
|
20
|
+
const users = await ctx.lobb.collectionStore.findAll({
|
|
21
|
+
collectionName: "auth_users",
|
|
22
|
+
params: { filter: { email } },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!users.data.length) {
|
|
26
|
+
throw new LobbError({
|
|
27
|
+
code: "NOT_FOUND",
|
|
28
|
+
message: `The user with this email (${email}) doesnt exist.`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const user = users.data[0] as User;
|
|
33
|
+
|
|
34
|
+
const passwordIsCorrect = await verify(user.password, password);
|
|
35
|
+
if (!passwordIsCorrect) {
|
|
36
|
+
throw new LobbError({
|
|
37
|
+
code: "UNAUTHORIZED",
|
|
38
|
+
message: "The password provided is incorrect. Please verify and try again.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sessionPayload = {
|
|
43
|
+
token: generateRandomId(),
|
|
44
|
+
expires_at: new Date(Date.now() + 3600 * 1000),
|
|
45
|
+
user_id: user.id,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sessionResult = await ctx.lobb.collectionStore.createOne({
|
|
49
|
+
collectionName: "auth_sessions",
|
|
50
|
+
data: sessionPayload,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
data: {
|
|
55
|
+
access_token: {
|
|
56
|
+
token: sessionPayload.token,
|
|
57
|
+
expires_at: sessionResult.data.expires_at,
|
|
58
|
+
},
|
|
59
|
+
user: {
|
|
60
|
+
id: user.id,
|
|
61
|
+
email: user.email,
|
|
62
|
+
role: user.role,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// POST /api/actions/auth_logout (bearer header → resolved to { token })
|
|
70
|
+
auth_logout: {
|
|
71
|
+
handler: async (input: any, ctx) => {
|
|
72
|
+
const token = input.token;
|
|
73
|
+
if (!token) {
|
|
74
|
+
throw new LobbError({
|
|
75
|
+
code: "BAD_REQUEST",
|
|
76
|
+
message: "You should pass session's token through the bearer header",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await ctx.lobb.collectionStore.deleteMany({
|
|
81
|
+
collectionName: "auth_sessions",
|
|
82
|
+
filter: { token },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { success: true };
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { Workflow } from "@lobb-js/core";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
|
-
import type { ExtensionConfig
|
|
4
|
-
import {
|
|
5
|
-
import { verify } from "argon2";
|
|
3
|
+
import type { ExtensionConfig } from "../config/extensionConfigSchema.ts";
|
|
4
|
+
import { getBearerToken } from "../utils.ts";
|
|
6
5
|
|
|
7
6
|
export function getBaseWorkflows(_extensionConfig: ExtensionConfig): Workflow[] {
|
|
8
7
|
return [
|
|
9
8
|
{
|
|
10
9
|
name: "auth_preventAdminUserDeletion",
|
|
11
|
-
eventName: "core.
|
|
10
|
+
eventName: "core.store.preDeleteOne",
|
|
12
11
|
handler: async (input, ctx) => {
|
|
13
|
-
if (
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
if (
|
|
13
|
+
input.collectionName === "auth_users" &&
|
|
14
|
+
input.triggeredBy === "API" &&
|
|
15
|
+
Number(input.id) === 1
|
|
16
|
+
) {
|
|
16
17
|
throw new ctx.LobbError({
|
|
17
18
|
code: "FORBIDDEN",
|
|
18
19
|
message: "The admin user cannot be deleted.",
|
|
@@ -22,11 +23,13 @@ export function getBaseWorkflows(_extensionConfig: ExtensionConfig): Workflow[]
|
|
|
22
23
|
},
|
|
23
24
|
{
|
|
24
25
|
name: "auth_preventAdminUserUpdate",
|
|
25
|
-
eventName: "core.
|
|
26
|
+
eventName: "core.store.preUpdateOne",
|
|
26
27
|
handler: async (input, ctx) => {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (
|
|
29
|
+
input.collectionName === "auth_users" &&
|
|
30
|
+
input.triggeredBy === "API" &&
|
|
31
|
+
Number(input.id) === 1
|
|
32
|
+
) {
|
|
30
33
|
throw new ctx.LobbError({
|
|
31
34
|
code: "FORBIDDEN",
|
|
32
35
|
message: "The admin user cannot be updated.",
|
|
@@ -48,167 +51,54 @@ export const baseWorkflows: Workflow[] = [
|
|
|
48
51
|
eventName: "core.webserver.middlwares.pre",
|
|
49
52
|
handler: async (input, ctx) => {
|
|
50
53
|
const token = getBearerToken(input.context);
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
const context = input.context as Context;
|
|
54
|
-
|
|
55
|
-
// 1) Try the token as a normal user session.
|
|
56
|
-
const sessionsResult = await ctx.workflows.collectionService({
|
|
57
|
-
method: "findAll",
|
|
58
|
-
props: {
|
|
59
|
-
collectionName: "auth_sessions",
|
|
60
|
-
params: { filter: { token } },
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
const session = sessionsResult.data[0];
|
|
54
|
+
if (token) {
|
|
55
|
+
const context = input.context as Context;
|
|
64
56
|
|
|
65
|
-
|
|
66
|
-
const
|
|
57
|
+
// 1) Try the token as a normal user session.
|
|
58
|
+
const sessionsResult = await ctx.workflows.collectionStore({
|
|
67
59
|
method: "findAll",
|
|
68
60
|
props: {
|
|
69
|
-
collectionName: "
|
|
70
|
-
params: { filter: {
|
|
61
|
+
collectionName: "auth_sessions",
|
|
62
|
+
params: { filter: { token } },
|
|
71
63
|
},
|
|
72
64
|
});
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// permissions snapshot and have a fixed expiry — no user identity.
|
|
82
|
-
const sharesResult = await ctx.workflows.collectionService({
|
|
83
|
-
method: "findAll",
|
|
84
|
-
props: {
|
|
85
|
-
collectionName: "auth_shares",
|
|
86
|
-
params: { filter: { token } },
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
const share = sharesResult.data[0];
|
|
90
|
-
|
|
91
|
-
if (share && new Date(share.expires_at) > new Date()) {
|
|
92
|
-
let permissions: any = {};
|
|
93
|
-
try {
|
|
94
|
-
permissions = JSON.parse(share.permissions ?? "{}");
|
|
95
|
-
} catch {
|
|
96
|
-
// malformed permissions — treat as no access
|
|
97
|
-
permissions = {};
|
|
98
|
-
}
|
|
99
|
-
context.set("auth_share", { ...share, permissions });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return input;
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
// auth_logins workflows
|
|
106
|
-
{
|
|
107
|
-
name: "auth_userLoginsHandler",
|
|
108
|
-
eventName: "core.controllers.preCreateOne",
|
|
109
|
-
handler: async (input, ctx) => {
|
|
110
|
-
if (input.collectionName === "auth_sessions") {
|
|
111
|
-
const payloadEmail = input.body.data.email;
|
|
112
|
-
const payloadPassword = input.body.data.password;
|
|
113
|
-
|
|
114
|
-
const users = await ctx.workflows.collectionService({
|
|
115
|
-
method: "findAll",
|
|
116
|
-
props: {
|
|
117
|
-
collectionName: "auth_users",
|
|
118
|
-
params: {
|
|
119
|
-
filter: {
|
|
120
|
-
email: payloadEmail,
|
|
121
|
-
},
|
|
65
|
+
const session = sessionsResult.data[0];
|
|
66
|
+
|
|
67
|
+
if (session) {
|
|
68
|
+
const usersResult = await ctx.workflows.collectionStore({
|
|
69
|
+
method: "findAll",
|
|
70
|
+
props: {
|
|
71
|
+
collectionName: "auth_users",
|
|
72
|
+
params: { filter: { id: session.user_id } },
|
|
122
73
|
},
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
if (!users.data.length) {
|
|
127
|
-
throw new ctx.LobbError({
|
|
128
|
-
code: "NOT_FOUND",
|
|
129
|
-
message: `The user with this email (${payloadEmail}) doesnt exist.`,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const user = users.data[0] as User;
|
|
134
|
-
const hashedPassword = user.password;
|
|
135
|
-
|
|
136
|
-
const passwordIsCorrect = await verify(
|
|
137
|
-
hashedPassword,
|
|
138
|
-
payloadPassword,
|
|
139
|
-
);
|
|
140
|
-
if (!passwordIsCorrect) {
|
|
141
|
-
throw new ctx.LobbError({
|
|
142
|
-
code: "UNAUTHORIZED",
|
|
143
|
-
message:
|
|
144
|
-
"The password provided is incorrect. Please verify and try again.",
|
|
145
74
|
});
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
data: sessionPayload,
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
throw new Response(
|
|
163
|
-
JSON.stringify({
|
|
164
|
-
data: {
|
|
165
|
-
access_token: {
|
|
166
|
-
token: sessionPayload.token,
|
|
167
|
-
expires_at: sessionResult.data.expires_at,
|
|
168
|
-
},
|
|
169
|
-
user: {
|
|
170
|
-
id: user.id,
|
|
171
|
-
email: user.email,
|
|
172
|
-
role: user.role,
|
|
173
|
-
},
|
|
75
|
+
const user = usersResult.data[0];
|
|
76
|
+
|
|
77
|
+
context.set("auth_session", session);
|
|
78
|
+
context.set("auth_user", user);
|
|
79
|
+
} else {
|
|
80
|
+
// 2) Otherwise try the token as a share. Shares carry their own
|
|
81
|
+
// permissions snapshot and have a fixed expiry — no user identity.
|
|
82
|
+
const sharesResult = await ctx.workflows.collectionStore({
|
|
83
|
+
method: "findAll",
|
|
84
|
+
props: {
|
|
85
|
+
collectionName: "auth_shares",
|
|
86
|
+
params: { filter: { token } },
|
|
174
87
|
},
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
name: "auth_userLogoutHandler",
|
|
182
|
-
eventName: "core.controllers.preDeleteMany",
|
|
183
|
-
handler: async (input, ctx) => {
|
|
184
|
-
if (input.collectionName === "auth_sessions") {
|
|
185
|
-
const context = input.context as Context;
|
|
186
|
-
const token = getBearerToken(context);
|
|
187
|
-
|
|
188
|
-
if (!token) {
|
|
189
|
-
throw new ctx.LobbError({
|
|
190
|
-
code: "BAD_REQUEST",
|
|
191
|
-
message:
|
|
192
|
-
"You should pass session's token through the bearer header",
|
|
193
88
|
});
|
|
89
|
+
const share = sharesResult.data[0];
|
|
90
|
+
|
|
91
|
+
if (share && new Date(share.expires_at) > new Date()) {
|
|
92
|
+
let permissions: any = {};
|
|
93
|
+
try {
|
|
94
|
+
permissions = JSON.parse(share.permissions ?? "{}");
|
|
95
|
+
} catch {
|
|
96
|
+
// malformed permissions — treat as no access
|
|
97
|
+
permissions = {};
|
|
98
|
+
}
|
|
99
|
+
context.set("auth_share", { ...share, permissions });
|
|
100
|
+
}
|
|
194
101
|
}
|
|
195
|
-
|
|
196
|
-
await ctx.workflows.collectionService({
|
|
197
|
-
method: "deleteMany",
|
|
198
|
-
props: {
|
|
199
|
-
collectionName: "auth_sessions",
|
|
200
|
-
filter: {
|
|
201
|
-
token: token,
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
throw new Response(
|
|
207
|
-
null,
|
|
208
|
-
{
|
|
209
|
-
status: 204,
|
|
210
|
-
},
|
|
211
|
-
);
|
|
212
102
|
}
|
|
213
103
|
},
|
|
214
104
|
},
|
|
@@ -15,18 +15,19 @@ export function getCurrentUserPermissionsWorkflow(
|
|
|
15
15
|
name: "auth_attachCurrentUserPermissions",
|
|
16
16
|
eventName: "core.controllers.findOne",
|
|
17
17
|
handler: async (input) => {
|
|
18
|
-
if (input.collectionName !== "auth_users") return input;
|
|
19
18
|
const context = input.context as Context | undefined;
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
if (
|
|
20
|
+
input.collectionName === "auth_users" &&
|
|
21
|
+
context?.req.param("id") === "me"
|
|
22
|
+
) {
|
|
23
|
+
const role = input.response?.data?.role;
|
|
24
|
+
// Strip functions (e.g. dynamic filter predicates) — they can't be
|
|
25
|
+
// serialised to the client.
|
|
26
|
+
const permissions = JSON.parse(JSON.stringify(
|
|
27
|
+
extensionConfig.roles?.[role]?.permissions ?? {},
|
|
28
|
+
));
|
|
29
|
+
input.response = { ...input.response, permissions };
|
|
30
|
+
}
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -5,6 +5,7 @@ import { meAliasWorkflows } from "./meAliasWorkflows.ts";
|
|
|
5
5
|
import { getCurrentUserPermissionsWorkflow } from "./currentUserPermissionsWorkflow.ts";
|
|
6
6
|
import { getBaseWorkflows } from "./baseWorkflow.ts";
|
|
7
7
|
import { getSharesWorkflows } from "./sharesWorkflows.ts";
|
|
8
|
+
import { getAuthActionControllerWorkflows } from "./actionController.ts";
|
|
8
9
|
import { init } from "../database/init.ts";
|
|
9
10
|
export function getWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
|
|
10
11
|
return [
|
|
@@ -17,6 +18,7 @@ export function getWorkflows(extensionConfig: ExtensionConfig): Workflow[] {
|
|
|
17
18
|
},
|
|
18
19
|
...getBaseWorkflows(extensionConfig),
|
|
19
20
|
...getSharesWorkflows(extensionConfig),
|
|
21
|
+
...getAuthActionControllerWorkflows(),
|
|
20
22
|
|
|
21
23
|
// TODO: think about putting the below workflows above at the beggining
|
|
22
24
|
// this will give us the ability to specify which roles have the ability to login, register etc
|
|
@@ -22,14 +22,15 @@ function resolveMeAlias(input: any, ctx: EventContext): number {
|
|
|
22
22
|
|
|
23
23
|
export const meAliasWorkflows: Workflow[] = [
|
|
24
24
|
{
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Share bearers don't have an auth_users row to fetch — take over the /me
|
|
26
|
+
// findOne at the controller boundary with the share's permissions snapshot.
|
|
27
|
+
// Studio uses the same /me endpoint to learn what the current bearer can do
|
|
28
|
+
// regardless of bearer type. Done at the controller level (not the store)
|
|
29
|
+
// so the HTTP response never originates from the data layer; the store-level
|
|
30
|
+
// resolveMeAlias below only runs for real users (where it resolves the id).
|
|
31
|
+
name: "auth_userFindOneMeShare",
|
|
32
|
+
eventName: "core.controllers.preFindOne",
|
|
27
33
|
handler: async (input, ctx) => {
|
|
28
|
-
// Share bearers don't have an auth_users row to fetch — short-circuit
|
|
29
|
-
// the findOne with the share's permissions snapshot. Studio uses the
|
|
30
|
-
// same /me endpoint to learn what the current bearer can do regardless
|
|
31
|
-
// of bearer type. Falls through to resolveMeAlias otherwise (which
|
|
32
|
-
// handles the session case and throws for unauthenticated requests).
|
|
33
34
|
if (input.collectionName === "auth_users" && input.id === "me") {
|
|
34
35
|
const context = input.context as Context;
|
|
35
36
|
const user = context.get("auth_user") as User | undefined;
|
|
@@ -38,7 +39,7 @@ export const meAliasWorkflows: Workflow[] = [
|
|
|
38
39
|
| { permissions: any }
|
|
39
40
|
| undefined;
|
|
40
41
|
if (share) {
|
|
41
|
-
|
|
42
|
+
return ctx.takeOver(new Response(
|
|
42
43
|
JSON.stringify({
|
|
43
44
|
data: null,
|
|
44
45
|
permissions: share.permissions,
|
|
@@ -47,12 +48,17 @@ export const meAliasWorkflows: Workflow[] = [
|
|
|
47
48
|
status: 200,
|
|
48
49
|
headers: { "Content-Type": "application/json" },
|
|
49
50
|
},
|
|
50
|
-
);
|
|
51
|
+
));
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "auth_userFindOneMeAlias",
|
|
59
|
+
eventName: "core.store.preFindOne",
|
|
60
|
+
handler: async (input, ctx) => {
|
|
54
61
|
input.id = resolveMeAlias(input, ctx);
|
|
55
|
-
return input;
|
|
56
62
|
},
|
|
57
63
|
},
|
|
58
64
|
{
|
|
@@ -60,7 +66,6 @@ export const meAliasWorkflows: Workflow[] = [
|
|
|
60
66
|
eventName: "core.store.preUpdateOne",
|
|
61
67
|
handler: async (input, ctx) => {
|
|
62
68
|
input.id = resolveMeAlias(input, ctx);
|
|
63
|
-
return input;
|
|
64
69
|
},
|
|
65
70
|
},
|
|
66
71
|
{
|
|
@@ -68,7 +73,6 @@ export const meAliasWorkflows: Workflow[] = [
|
|
|
68
73
|
eventName: "core.store.preDeleteOne",
|
|
69
74
|
handler: async (input, ctx) => {
|
|
70
75
|
input.id = resolveMeAlias(input, ctx);
|
|
71
|
-
return input;
|
|
72
76
|
},
|
|
73
77
|
},
|
|
74
78
|
];
|
|
@@ -33,7 +33,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
33
33
|
});
|
|
34
34
|
if (output?.payload) input.data = output.payload;
|
|
35
35
|
}
|
|
36
|
-
return input;
|
|
37
36
|
},
|
|
38
37
|
},
|
|
39
38
|
{
|
|
@@ -60,7 +59,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
60
59
|
});
|
|
61
60
|
if (output?.filter) input.filter = output.filter;
|
|
62
61
|
}
|
|
63
|
-
return input;
|
|
64
62
|
},
|
|
65
63
|
},
|
|
66
64
|
{
|
|
@@ -88,7 +86,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
88
86
|
});
|
|
89
87
|
if (output?.payload) input.data = output.payload;
|
|
90
88
|
}
|
|
91
|
-
return input;
|
|
92
89
|
},
|
|
93
90
|
},
|
|
94
91
|
{
|
|
@@ -114,7 +111,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
114
111
|
recordId: input.id,
|
|
115
112
|
});
|
|
116
113
|
}
|
|
117
|
-
return input;
|
|
118
114
|
},
|
|
119
115
|
},
|
|
120
116
|
// Post-fetch field-level filtering using the role/share's `read.fields`
|
|
@@ -132,7 +128,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
132
128
|
);
|
|
133
129
|
input.data = handleReadFields(permissions, input.collectionName, input.data);
|
|
134
130
|
}
|
|
135
|
-
return input;
|
|
136
131
|
},
|
|
137
132
|
},
|
|
138
133
|
{
|
|
@@ -148,7 +143,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
148
143
|
);
|
|
149
144
|
input.data = handleReadFields(permissions, input.collectionName, input.data);
|
|
150
145
|
}
|
|
151
|
-
return input;
|
|
152
146
|
},
|
|
153
147
|
},
|
|
154
148
|
{
|
|
@@ -164,7 +158,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
164
158
|
);
|
|
165
159
|
input.data = handleReadFields(permissions, input.collectionName, input.data);
|
|
166
160
|
}
|
|
167
|
-
return input;
|
|
168
161
|
},
|
|
169
162
|
},
|
|
170
163
|
{
|
|
@@ -180,7 +173,6 @@ export function getPoliciesWorkflows(extensionConfig: ExtensionConfig): Workflow
|
|
|
180
173
|
);
|
|
181
174
|
input.data = handleReadFields(permissions, input.collectionName, input.data);
|
|
182
175
|
}
|
|
183
|
-
return input;
|
|
184
176
|
},
|
|
185
177
|
},
|
|
186
178
|
];
|
|
@@ -22,44 +22,42 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
|
|
|
22
22
|
if (input.collectionName === "auth_shares") {
|
|
23
23
|
input.data.token = generateRandomId();
|
|
24
24
|
}
|
|
25
|
-
return input;
|
|
26
25
|
},
|
|
27
26
|
},
|
|
28
27
|
// Normalize the two ways a caller can specify share lifetime into the
|
|
29
|
-
// single `expires_at` column the rest of the system uses.
|
|
30
|
-
//
|
|
31
|
-
// check — so `expires_at` can stay schema-required
|
|
32
|
-
// callers send only `expires_in_seconds`. Keeps an
|
|
33
|
-
// provided" throw for a friendlier error message than the
|
|
34
|
-
// required-field error.
|
|
28
|
+
// single `expires_at` column the rest of the system uses. `order: "pre"`
|
|
29
|
+
// makes it run before the unordered subscribers of preCreateOne — crucially
|
|
30
|
+
// before the required-field check — so `expires_at` can stay schema-required
|
|
31
|
+
// while still letting callers send only `expires_in_seconds`. Keeps an
|
|
32
|
+
// explicit "neither provided" throw for a friendlier error message than the
|
|
33
|
+
// generic required-field error.
|
|
35
34
|
{
|
|
36
35
|
name: "auth_normalizeShareExpiry",
|
|
37
|
-
eventName: "core.
|
|
36
|
+
eventName: "core.store.preCreateOne",
|
|
37
|
+
order: "pre",
|
|
38
38
|
handler: async (input, ctx) => {
|
|
39
|
-
if (input.collectionName
|
|
39
|
+
if (input.collectionName === "auth_shares") {
|
|
40
|
+
const seconds = input.data.expires_in_seconds;
|
|
41
|
+
if (typeof seconds === "number") {
|
|
42
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
43
|
+
throw new ctx.LobbError({
|
|
44
|
+
code: "BAD_REQUEST",
|
|
45
|
+
message: "expires_in_seconds must be a positive number.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
input.data.expires_at = new Date(Date.now() + seconds * 1000)
|
|
49
|
+
.toISOString();
|
|
50
|
+
delete input.data.expires_in_seconds;
|
|
51
|
+
}
|
|
40
52
|
|
|
41
|
-
|
|
42
|
-
if (typeof seconds === "number") {
|
|
43
|
-
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
53
|
+
if (!input.data.expires_at) {
|
|
44
54
|
throw new ctx.LobbError({
|
|
45
55
|
code: "BAD_REQUEST",
|
|
46
|
-
message:
|
|
56
|
+
message:
|
|
57
|
+
"Must provide either expires_at or expires_in_seconds when creating a share.",
|
|
47
58
|
});
|
|
48
59
|
}
|
|
49
|
-
input.data.expires_at = new Date(Date.now() + seconds * 1000)
|
|
50
|
-
.toISOString();
|
|
51
|
-
delete input.data.expires_in_seconds;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!input.data.expires_at) {
|
|
55
|
-
throw new ctx.LobbError({
|
|
56
|
-
code: "BAD_REQUEST",
|
|
57
|
-
message:
|
|
58
|
-
"Must provide either expires_at or expires_in_seconds when creating a share.",
|
|
59
|
-
});
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
return input;
|
|
63
61
|
},
|
|
64
62
|
},
|
|
65
63
|
// Auto-set created_by from the authenticated request user so callers
|
|
@@ -70,52 +68,54 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
|
|
|
70
68
|
name: "auth_setShareCreatedBy",
|
|
71
69
|
eventName: "core.store.preCreateOne",
|
|
72
70
|
handler: async (input) => {
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
if (
|
|
72
|
+
input.collectionName === "auth_shares" &&
|
|
73
|
+
input.triggeredBy === "API"
|
|
74
|
+
) {
|
|
75
|
+
const context = input.context as Context;
|
|
76
|
+
const user = context.get("auth_user") as User | undefined;
|
|
77
|
+
if (user) input.data.created_by = user.id;
|
|
78
|
+
}
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
// Intersection guard: a share's embedded permissions snapshot must be a
|
|
82
82
|
// subset of the creator's own permissions. Without this, any user who can
|
|
83
83
|
// create rows in auth_shares could mint a token that grants admin-level
|
|
84
84
|
// access. Only runs for API-triggered creates — internal callers (server
|
|
85
|
-
// code, tests using
|
|
85
|
+
// code, tests using collectionStore directly) are trusted.
|
|
86
86
|
{
|
|
87
87
|
name: "auth_validateShareSubset",
|
|
88
88
|
eventName: "core.store.preCreateOne",
|
|
89
89
|
handler: async (input, ctx) => {
|
|
90
|
-
if (
|
|
91
|
-
|
|
90
|
+
if (
|
|
91
|
+
input.collectionName === "auth_shares" &&
|
|
92
|
+
input.triggeredBy === "API"
|
|
93
|
+
) {
|
|
94
|
+
const context = input.context as Context;
|
|
95
|
+
const { permissions: creatorPermissions } = resolveRequestPermissions(
|
|
96
|
+
context,
|
|
97
|
+
extensionConfig,
|
|
98
|
+
ctx,
|
|
99
|
+
);
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
sharePermissions = JSON.parse(input.data.permissions);
|
|
103
|
-
} catch {
|
|
104
|
-
throw new ctx.LobbError({
|
|
105
|
-
code: "BAD_REQUEST",
|
|
106
|
-
message: "Share permissions must be valid JSON.",
|
|
107
|
-
});
|
|
108
|
-
}
|
|
101
|
+
let sharePermissions: PermissionsConfig;
|
|
102
|
+
try {
|
|
103
|
+
sharePermissions = JSON.parse(input.data.permissions);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new ctx.LobbError({
|
|
106
|
+
code: "BAD_REQUEST",
|
|
107
|
+
message: "Share permissions must be valid JSON.",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
if (!isShareSubsetOfCreator(sharePermissions, creatorPermissions)) {
|
|
112
|
+
throw new ctx.LobbError({
|
|
113
|
+
code: "FORBIDDEN",
|
|
114
|
+
message:
|
|
115
|
+
"Cannot create a share with broader permissions than your own.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
116
118
|
}
|
|
117
|
-
|
|
118
|
-
return input;
|
|
119
119
|
},
|
|
120
120
|
},
|
|
121
121
|
// Hourly housekeeping: delete shares whose expires_at is in the past.
|
|
@@ -125,7 +125,7 @@ export function getSharesWorkflows(extensionConfig: ExtensionConfig): Workflow[]
|
|
|
125
125
|
name: "auth_cleanupExpiredShares",
|
|
126
126
|
eventName: "0 3 * * *",
|
|
127
127
|
handler: async (_input, ctx) => {
|
|
128
|
-
await ctx.lobb.
|
|
128
|
+
await ctx.lobb.collectionStore.deleteMany({
|
|
129
129
|
collectionName: "auth_shares",
|
|
130
130
|
filter: { expires_at: { $lt: new Date().toISOString() } },
|
|
131
131
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobb-js/lobb-ext-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"license": "UNLICENSED",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"package": "svelte-package --input extensions/auth/studio"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@lobb-js/core": "^0.
|
|
35
|
+
"@lobb-js/core": "^0.42.0",
|
|
36
36
|
"argon2": "^0.40.3",
|
|
37
37
|
"hono": "^4.7.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@lobb-js/studio": "^0.
|
|
40
|
+
"@lobb-js/studio": "^0.50.0",
|
|
41
41
|
"@lucide/svelte": "^0.563.1",
|
|
42
42
|
"@sveltejs/adapter-node": "^5.5.4",
|
|
43
43
|
"@sveltejs/kit": "^2.60.1",
|