@things-factory/auth-base 10.1.250 → 10.1.256
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-server/router/webauthn-router.js +18 -5
- package/dist-server/router/webauthn-router.js.map +1 -1
- package/dist-server/service/audit/audit-work.d.ts +23 -0
- package/dist-server/service/audit/audit-work.js +43 -0
- package/dist-server/service/audit/audit-work.js.map +1 -0
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +5 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/tests/audit-work.test.ts +58 -0
- package/tests/passkey-signin-route-db.test.ts +139 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.256",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"@reduxjs/toolkit": "^2.2.5",
|
|
36
36
|
"@simplewebauthn/browser": "^13.0.0",
|
|
37
37
|
"@simplewebauthn/server": "^13.0.0",
|
|
38
|
-
"@things-factory/email-base": "^10.1.
|
|
38
|
+
"@things-factory/email-base": "^10.1.254",
|
|
39
39
|
"@things-factory/env": "^10.1.149",
|
|
40
|
-
"@things-factory/shell": "^10.1.
|
|
41
|
-
"@things-factory/task-lease": "^10.1.
|
|
40
|
+
"@things-factory/shell": "^10.1.254",
|
|
41
|
+
"@things-factory/task-lease": "^10.1.254",
|
|
42
42
|
"@things-factory/utils": "^10.1.179",
|
|
43
43
|
"@types/webappsec-credential-management": "^0.6.9",
|
|
44
44
|
"jsonwebtoken": "^9.0.0",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"passport-jwt": "^4.0.0",
|
|
51
51
|
"passport-local": "^1.0.0"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "2fd641697190ce6e87bcf454a60b2321553e8110"
|
|
54
54
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work done through the doors (ADR-0185 decision 6) - read from the audit: a declared mutation that went through is a
|
|
3
|
+
* piece of the person's work on that day; an undeclared one, a failed one, another person's or another day's is not.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
7
|
+
import { loadCompiled } from './compiled'
|
|
8
|
+
|
|
9
|
+
const { registerWorkDoors, workDoneBy } = loadCompiled('service/audit/audit-work')
|
|
10
|
+
const { AuditAction } = loadCompiled('service/audit/audit-action')
|
|
11
|
+
const { AuditRequest } = loadCompiled('service/audit/audit-request')
|
|
12
|
+
const { User } = loadCompiled('service/user/user')
|
|
13
|
+
|
|
14
|
+
let domain: any, kim: any, lee: any
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
await openAuthDatabase()
|
|
18
|
+
})
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await closeAuthDatabase()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await resetAuthDatabase()
|
|
25
|
+
domain = await getRepository(Domain).save({ name: 'work', subdomain: 'work' })
|
|
26
|
+
kim = await getRepository(User).save({ name: 'kim', email: 'kim@work.test', status: 'activated' })
|
|
27
|
+
lee = await getRepository(User).save({ name: 'lee', email: 'lee@work.test', status: 'activated' })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const request = async (actor: any, at: string, fields: string, outcome: 'ok' | 'error' | 'both' | null, operationType = 'mutation') => {
|
|
31
|
+
const one = await getRepository(AuditRequest).save({ id: require('crypto').randomUUID(), domain, actor, createdAt: new Date(at), channel: 'graphql', operationType, fields })
|
|
32
|
+
for (const each of outcome === 'both' ? ['ok', 'error'] : outcome ? [outcome] : [])
|
|
33
|
+
await getRepository(AuditAction).save({ domain, actor, requestId: one.id, createdAt: new Date(at), kind: 'door.result', door: 'office:mutation', outcome: each })
|
|
34
|
+
return one
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('a declared mutation that went through is a piece of work, once per request and work', async () => {
|
|
38
|
+
registerWorkDoors([
|
|
39
|
+
{ work: 'work.office.bank-lines', fields: ['updateMultipleCashTransaction', 'createCashTransaction'] },
|
|
40
|
+
{ work: 'work.office.tax-invoices', fields: ['importOfficeTaxInvoices'] }
|
|
41
|
+
])
|
|
42
|
+
expect(() => registerWorkDoors([{ work: 'work.other', fields: ['importOfficeTaxInvoices'] }])).toThrow(/declared as the work/)
|
|
43
|
+
|
|
44
|
+
await request(kim, '2026-11-10T01:00:00Z', 'updateMultipleCashTransaction,createCashTransaction', 'ok')
|
|
45
|
+
await request(kim, '2026-11-10T02:00:00Z', 'importOfficeTaxInvoices', 'ok')
|
|
46
|
+
await request(kim, '2026-11-10T03:00:00Z', 'importOfficeTaxInvoices', 'error')
|
|
47
|
+
await request(kim, '2026-11-10T03:30:00Z', 'createCashTransaction,importOfficeTaxInvoices', 'both')
|
|
48
|
+
await request(kim, '2026-11-10T04:00:00Z', 'saveMyDraft', 'ok')
|
|
49
|
+
await request(kim, '2026-11-10T05:00:00Z', 'importOfficeTaxInvoices', 'ok', 'query')
|
|
50
|
+
await request(lee, '2026-11-10T06:00:00Z', 'importOfficeTaxInvoices', 'ok')
|
|
51
|
+
await request(kim, '2026-11-17T01:00:00Z', 'importOfficeTaxInvoices', 'ok')
|
|
52
|
+
|
|
53
|
+
const done = await workDoneBy(authDataSource().manager, domain, kim.id, new Date('2026-11-09T00:00:00Z'), new Date('2026-11-16T00:00:00Z'))
|
|
54
|
+
expect(done.map((one: any) => [one.at.toISOString(), one.work])).toEqual([
|
|
55
|
+
['2026-11-10T01:00:00.000Z', 'work.office.bank-lines'],
|
|
56
|
+
['2026-11-10T02:00:00.000Z', 'work.office.tax-invoices']
|
|
57
|
+
])
|
|
58
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The passkey sign-in route, through `authenticate401Middleware`, the way the sign-in screen reaches it.
|
|
3
|
+
*
|
|
4
|
+
* The screen used to fetch it. A fetch says `Accept: *\/*`, the middleware takes that for a page request,
|
|
5
|
+
* so a locked account got the whole `auth-activate` page back as the body - and the screen put that body
|
|
6
|
+
* in a toast. Now the screen posts a form, as the password does, and every refusal is answered with the
|
|
7
|
+
* page its code leads to: the activate page for a locked or unconfirmed account, the sign-in page with
|
|
8
|
+
* its one line for the rest.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
|
|
12
|
+
import { loadCompiled } from './compiled'
|
|
13
|
+
import { SoftwareAuthenticator } from './passkey-authenticator'
|
|
14
|
+
|
|
15
|
+
const { User } = loadCompiled('service/user/user')
|
|
16
|
+
const { authenticate401Middleware } = loadCompiled('middlewares/authenticate-401-middleware')
|
|
17
|
+
const { webAuthnGlobalPublicRouter } = loadCompiled('router/webauthn-router')
|
|
18
|
+
const CODES = loadCompiled('constants/error-code')
|
|
19
|
+
const passkey = loadCompiled('controllers/passkey')
|
|
20
|
+
|
|
21
|
+
const SITE = { rpID: 'localhost', origin: 'http://localhost:3000' }
|
|
22
|
+
const NAVIGATION = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
23
|
+
|
|
24
|
+
let domain: any
|
|
25
|
+
let kim: any
|
|
26
|
+
|
|
27
|
+
const signinRoute = webAuthnGlobalPublicRouter.stack.find(
|
|
28
|
+
(layer: any) => layer.path === '/auth/signin-webauthn' && layer.methods.includes('POST')
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
/** What the browser gets back for the form the screen posts. */
|
|
32
|
+
async function post(body: (challenge: string) => any) {
|
|
33
|
+
const { challenge } = await passkey.signinOptions(SITE)
|
|
34
|
+
const answer: any = { status: 200, cookies: {} }
|
|
35
|
+
|
|
36
|
+
const context: any = {
|
|
37
|
+
t: (key: string) => key,
|
|
38
|
+
method: 'POST',
|
|
39
|
+
path: '/auth/signin-webauthn',
|
|
40
|
+
originalUrl: '/auth/signin-webauthn',
|
|
41
|
+
header: { accept: NAVIGATION },
|
|
42
|
+
hostname: SITE.rpID,
|
|
43
|
+
origin: SITE.origin,
|
|
44
|
+
secure: false,
|
|
45
|
+
query: {},
|
|
46
|
+
session: { challenge },
|
|
47
|
+
state: { domain },
|
|
48
|
+
request: { body: body(challenge) },
|
|
49
|
+
cookies: { set: (key: string, value: string) => (answer.cookies[key] = value) },
|
|
50
|
+
render: async (view: string, options: any) => (answer.page = { view, ...options }),
|
|
51
|
+
redirect: (url: string) => (answer.redirect = url)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await authenticate401Middleware(context, () => signinRoute.stack[0](context, async () => {}))
|
|
55
|
+
|
|
56
|
+
return { ...answer, status: context.status, body: context.body }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function registered(user: any) {
|
|
60
|
+
const authenticator = new SoftwareAuthenticator()
|
|
61
|
+
const { challenge } = await passkey.registrationOptions(user, 'Operato', SITE)
|
|
62
|
+
await passkey.registerPasskey(user, authenticator.register(challenge, SITE), 'macOS · Chrome', challenge, SITE)
|
|
63
|
+
return authenticator
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const formWith = (authenticator: SoftwareAuthenticator) => (challenge: string) => ({
|
|
67
|
+
response: JSON.stringify(authenticator.assert(challenge, SITE)),
|
|
68
|
+
redirectTo: '/office'
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
beforeAll(async () => {
|
|
72
|
+
await openAuthDatabase()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await closeAuthDatabase()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
await resetAuthDatabase()
|
|
81
|
+
domain = await getRepository(Domain).save({ name: 'factory', subdomain: 'factory' })
|
|
82
|
+
|
|
83
|
+
const salt = User.generateSalt()
|
|
84
|
+
kim = await getRepository(User).save({
|
|
85
|
+
username: 'kim',
|
|
86
|
+
email: 'kim@example.com',
|
|
87
|
+
name: 'kim',
|
|
88
|
+
salt,
|
|
89
|
+
password: User.encode('Passw0rd!', salt),
|
|
90
|
+
status: 'activated',
|
|
91
|
+
domains: [domain]
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('signs in and goes on to check-in, with the token in the cookie', async () => {
|
|
96
|
+
const answer = await post(formWith(await registered(kim)))
|
|
97
|
+
|
|
98
|
+
expect(answer.redirect).toBe('/auth/checkin/factory?redirect_to=%2Foffice')
|
|
99
|
+
expect(Object.values(answer.cookies)).toHaveLength(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe.each([
|
|
103
|
+
['locked', CODES.USER_LOCKED],
|
|
104
|
+
['inactive', CODES.USER_NOT_ACTIVATED]
|
|
105
|
+
])('a %s account', (status, errorCode) => {
|
|
106
|
+
it('lands on the activate page with its code and its line, the way a password does', async () => {
|
|
107
|
+
const authenticator = await registered(kim)
|
|
108
|
+
await getRepository(User).update({ id: kim.id }, { status, lockedAt: new Date() })
|
|
109
|
+
|
|
110
|
+
const answer = await post(formWith(authenticator))
|
|
111
|
+
|
|
112
|
+
expect(answer.status).toBe(401)
|
|
113
|
+
expect(answer.page.pageElement).toBe('auth-activate')
|
|
114
|
+
expect(answer.page.data).toMatchObject({ errorCode, message: `error.${errorCode}`, username: 'kim', redirectTo: '/office' })
|
|
115
|
+
expect(answer.cookies).toEqual({})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe.each([
|
|
120
|
+
['deleted', CODES.USER_DELETED],
|
|
121
|
+
['password_reset_required', CODES.PASSWORD_SETUP_PENDING]
|
|
122
|
+
])('a %s account', (status, errorCode) => {
|
|
123
|
+
it('stays on the sign-in page with its one line', async () => {
|
|
124
|
+
const authenticator = await registered(kim)
|
|
125
|
+
await getRepository(User).update({ id: kim.id }, { status })
|
|
126
|
+
|
|
127
|
+
const answer = await post(formWith(authenticator))
|
|
128
|
+
|
|
129
|
+
expect(answer.page.pageElement).toBe('auth-signin')
|
|
130
|
+
expect(answer.page.data.message).toBe(`error.${errorCode}`)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('an answer that is not JSON is a failed verification, said in one line on the sign-in page', async () => {
|
|
135
|
+
const answer = await post(() => ({ response: '<not json>', redirectTo: '/office' }))
|
|
136
|
+
|
|
137
|
+
expect(answer.page.pageElement).toBe('auth-signin')
|
|
138
|
+
expect(answer.page.data.message).toBe(`error.${CODES.AUTHN_VERIFICATION_FAILED}`)
|
|
139
|
+
})
|