@things-factory/auth-base 10.1.250 → 10.1.254
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.254",
|
|
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": "793e2fd617db5bb917ec892e64956fb690f7e7b0"
|
|
54
54
|
}
|
|
@@ -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
|
+
})
|