@things-factory/auth-base 10.1.206 → 10.1.208
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/controllers/passkey.d.ts +35 -0
- package/dist-server/controllers/passkey.js +182 -0
- package/dist-server/controllers/passkey.js.map +1 -0
- package/dist-server/controllers/signin.d.ts +24 -0
- package/dist-server/controllers/signin.js +63 -39
- package/dist-server/controllers/signin.js.map +1 -1
- package/dist-server/middlewares/index.d.ts +0 -1
- package/dist-server/middlewares/index.js +0 -1
- package/dist-server/middlewares/index.js.map +1 -1
- package/dist-server/router/auth-signin-router.js +2 -1
- package/dist-server/router/auth-signin-router.js.map +1 -1
- package/dist-server/router/webauthn-router.js +55 -93
- package/dist-server/router/webauthn-router.js.map +1 -1
- package/dist-server/service/access-request/index.d.ts +1 -1
- package/dist-server/service/audit/audit-kinds.js +3 -1
- package/dist-server/service/audit/audit-kinds.js.map +1 -1
- package/dist-server/service/audit/index.d.ts +1 -1
- package/dist-server/service/domain-link/index.d.ts +1 -1
- package/dist-server/service/domain-owner/index.d.ts +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/service/index.js +2 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/open-fields.js +4 -1
- package/dist-server/service/open-fields.js.map +1 -1
- package/dist-server/service/role-template/index.d.ts +1 -1
- package/dist-server/service/web-auth-credential/index.d.ts +3 -0
- package/dist-server/service/web-auth-credential/index.js +4 -1
- package/dist-server/service/web-auth-credential/index.js.map +1 -1
- package/dist-server/service/web-auth-credential/web-auth-credential-mutation.d.ts +7 -0
- package/dist-server/service/web-auth-credential/web-auth-credential-mutation.js +67 -0
- package/dist-server/service/web-auth-credential/web-auth-credential-mutation.js.map +1 -0
- package/dist-server/service/web-auth-credential/web-auth-credential-query.d.ts +5 -0
- package/dist-server/service/web-auth-credential/web-auth-credential-query.js +29 -0
- package/dist-server/service/web-auth-credential/web-auth-credential-query.js.map +1 -0
- package/dist-server/service/web-auth-credential/web-auth-credential.d.ts +4 -0
- package/dist-server/service/web-auth-credential/web-auth-credential.js +24 -2
- package/dist-server/service/web-auth-credential/web-auth-credential.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/tests/audit-db.test.ts +4 -4
- package/tests/passkey-authenticator.ts +104 -0
- package/tests/passkey-db.test.ts +286 -0
- package/translations/ko.json +2 -2
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.208",
|
|
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.208",
|
|
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.208",
|
|
41
|
+
"@things-factory/task-lease": "^10.1.208",
|
|
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": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
|
|
54
54
|
}
|
package/tests/audit-db.test.ts
CHANGED
|
@@ -289,10 +289,10 @@ describe('signing in (§3.3)', () => {
|
|
|
289
289
|
await flushAudit()
|
|
290
290
|
|
|
291
291
|
const rows = await getRepository(AuditAction).find({ where: { kind: 'auth.signin' }, relations: ['actor'], order: { createdAt: 'ASC' } })
|
|
292
|
-
expect(rows.map(row => [row.outcome, row.code, row.actor?.id ?? null])).toEqual([
|
|
293
|
-
['ok', null, kim.id],
|
|
294
|
-
['error', 'password-not-matched', kim.id],
|
|
295
|
-
['error', 'user not found', null]
|
|
292
|
+
expect(rows.map(row => [row.outcome, row.code, row.actor?.id ?? null, row.detail?.method])).toEqual([
|
|
293
|
+
['ok', null, kim.id, 'password'],
|
|
294
|
+
['error', 'password-not-matched', kim.id, 'password'],
|
|
295
|
+
['error', 'user not found', null, 'password']
|
|
296
296
|
])
|
|
297
297
|
})
|
|
298
298
|
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A passkey authenticator in software, for the tests.
|
|
3
|
+
*
|
|
4
|
+
* It answers registration and sign-in the way a browser hands them to the server - the same JSON
|
|
5
|
+
* `@simplewebauthn/browser` produces - with an ES256 key held here. `userVerified` sets the UV flag:
|
|
6
|
+
* the authenticator saying it checked a fingerprint, face or PIN. Nothing here verifies anything;
|
|
7
|
+
* that is the server's part, and the tests watch the server.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash, generateKeyPairSync, randomBytes, sign as signData, type KeyObject } from 'crypto'
|
|
11
|
+
|
|
12
|
+
const { isoCBOR } = require('@simplewebauthn/server/helpers')
|
|
13
|
+
|
|
14
|
+
const FLAG_UP = 0x01
|
|
15
|
+
const FLAG_UV = 0x04
|
|
16
|
+
const FLAG_AT = 0x40
|
|
17
|
+
|
|
18
|
+
const b64url = (bytes: Uint8Array | Buffer) => Buffer.from(bytes).toString('base64url')
|
|
19
|
+
const sha256 = (data: Buffer | string) => createHash('sha256').update(data).digest()
|
|
20
|
+
|
|
21
|
+
export type Site = { rpID: string; origin: string }
|
|
22
|
+
|
|
23
|
+
export class SoftwareAuthenticator {
|
|
24
|
+
readonly credentialId = randomBytes(16)
|
|
25
|
+
private readonly privateKey: KeyObject
|
|
26
|
+
private readonly publicJwk: { x: string; y: string }
|
|
27
|
+
private counter = 0
|
|
28
|
+
|
|
29
|
+
constructor() {
|
|
30
|
+
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
31
|
+
this.privateKey = privateKey
|
|
32
|
+
this.publicJwk = publicKey.export({ format: 'jwk' }) as any
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get id(): string {
|
|
36
|
+
return b64url(this.credentialId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private authenticatorData(site: Site, flags: number, attested?: Buffer): Buffer {
|
|
40
|
+
const count = Buffer.alloc(4)
|
|
41
|
+
count.writeUInt32BE(this.counter)
|
|
42
|
+
return Buffer.concat([sha256(site.rpID), Buffer.from([flags]), count, ...(attested ? [attested] : [])])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private clientData(type: string, challenge: string, site: Site): Buffer {
|
|
46
|
+
return Buffer.from(JSON.stringify({ type, challenge, origin: site.origin, crossOrigin: false }))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The browser's answer to `navigator.credentials.create`. */
|
|
50
|
+
register(challenge: string, site: Site, { userVerified = true } = {}) {
|
|
51
|
+
const cose = new Map<number, number | Uint8Array>([
|
|
52
|
+
[1, 2],
|
|
53
|
+
[3, -7],
|
|
54
|
+
[-1, 1],
|
|
55
|
+
[-2, new Uint8Array(Buffer.from(this.publicJwk.x, 'base64url'))],
|
|
56
|
+
[-3, new Uint8Array(Buffer.from(this.publicJwk.y, 'base64url'))]
|
|
57
|
+
])
|
|
58
|
+
const idLength = Buffer.alloc(2)
|
|
59
|
+
idLength.writeUInt16BE(this.credentialId.length)
|
|
60
|
+
const attested = Buffer.concat([Buffer.alloc(16), idLength, this.credentialId, Buffer.from(isoCBOR.encode(cose))])
|
|
61
|
+
|
|
62
|
+
const authData = this.authenticatorData(site, FLAG_UP | FLAG_AT | (userVerified ? FLAG_UV : 0), attested)
|
|
63
|
+
const attestationObject = isoCBOR.encode(
|
|
64
|
+
new Map<string, unknown>([
|
|
65
|
+
['fmt', 'none'],
|
|
66
|
+
['attStmt', new Map()],
|
|
67
|
+
['authData', new Uint8Array(authData)]
|
|
68
|
+
])
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: this.id,
|
|
73
|
+
rawId: this.id,
|
|
74
|
+
type: 'public-key',
|
|
75
|
+
clientExtensionResults: {},
|
|
76
|
+
response: {
|
|
77
|
+
clientDataJSON: b64url(this.clientData('webauthn.create', challenge, site)),
|
|
78
|
+
attestationObject: b64url(attestationObject),
|
|
79
|
+
transports: ['internal', 'hybrid']
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The browser's answer to `navigator.credentials.get`. */
|
|
85
|
+
assert(challenge: string, site: Site, { userVerified = true } = {}) {
|
|
86
|
+
this.counter++
|
|
87
|
+
const authData = this.authenticatorData(site, FLAG_UP | (userVerified ? FLAG_UV : 0))
|
|
88
|
+
const clientDataJSON = this.clientData('webauthn.get', challenge, site)
|
|
89
|
+
const signature = signData('sha256', Buffer.concat([authData, sha256(clientDataJSON)]), this.privateKey)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
id: this.id,
|
|
93
|
+
rawId: this.id,
|
|
94
|
+
type: 'public-key',
|
|
95
|
+
clientExtensionResults: {},
|
|
96
|
+
response: {
|
|
97
|
+
authenticatorData: b64url(authData),
|
|
98
|
+
clientDataJSON: b64url(clientDataJSON),
|
|
99
|
+
signature: b64url(signature),
|
|
100
|
+
userHandle: null
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkeys (ADR-0156) - registration, sign-in, confirmation and the caller's own list, through the
|
|
3
|
+
* real controllers and resolvers on the sqlite harness. The authenticator is software
|
|
4
|
+
* (`passkey-authenticator.ts`); what is watched is what the server accepts and what it writes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase, authDataSource } from './db'
|
|
8
|
+
import { loadCompiled } from './compiled'
|
|
9
|
+
import { SoftwareAuthenticator } from './passkey-authenticator'
|
|
10
|
+
|
|
11
|
+
const { User } = loadCompiled('service/user/user')
|
|
12
|
+
const { WebAuthCredential } = loadCompiled('service/web-auth-credential/web-auth-credential')
|
|
13
|
+
const { WebAuthCredentialQuery } = loadCompiled('service/web-auth-credential/web-auth-credential-query')
|
|
14
|
+
const { WebAuthCredentialMutation } = loadCompiled('service/web-auth-credential/web-auth-credential-mutation')
|
|
15
|
+
const { AuditAction } = loadCompiled('service/audit/audit-action')
|
|
16
|
+
const { flushAudit } = loadCompiled('service/audit/audit-recorder')
|
|
17
|
+
const passkey = loadCompiled('controllers/passkey')
|
|
18
|
+
|
|
19
|
+
const SITE = { rpID: 'localhost', origin: 'http://localhost:3000' }
|
|
20
|
+
const PASSWORD = 'Passw0rd!'
|
|
21
|
+
|
|
22
|
+
let domain: any
|
|
23
|
+
let kim: any
|
|
24
|
+
let lee: any
|
|
25
|
+
|
|
26
|
+
async function seedUser(username: string, overrides: any = {}) {
|
|
27
|
+
const salt = User.generateSalt()
|
|
28
|
+
return await getRepository(User).save({
|
|
29
|
+
username,
|
|
30
|
+
email: `${username}@example.com`,
|
|
31
|
+
name: username,
|
|
32
|
+
salt,
|
|
33
|
+
password: User.encode(PASSWORD, salt),
|
|
34
|
+
status: 'activated',
|
|
35
|
+
domains: [domain],
|
|
36
|
+
...overrides
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A challenge the way the router makes one: from the options, into the session, taken back out. */
|
|
41
|
+
async function signinChallenge(): Promise<string> {
|
|
42
|
+
const context: any = { session: {} }
|
|
43
|
+
passkey.rememberChallenge(context, (await passkey.signinOptions(SITE)).challenge)
|
|
44
|
+
return passkey.takeChallenge(context)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function registered(user: any, name = 'macOS · Chrome') {
|
|
48
|
+
const authenticator = new SoftwareAuthenticator()
|
|
49
|
+
const { challenge } = await passkey.registrationOptions(user, 'Operato', SITE)
|
|
50
|
+
await passkey.registerPasskey(user, authenticator.register(challenge, SITE), name, challenge, SITE)
|
|
51
|
+
return authenticator
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function signinWith(authenticator: SoftwareAuthenticator, options: { userVerified?: boolean } = {}) {
|
|
55
|
+
const challenge = await signinChallenge()
|
|
56
|
+
return await passkey.signinWithPasskey(authenticator.assert(challenge, SITE, options), challenge, SITE)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const credentialOf = (authenticator: SoftwareAuthenticator) =>
|
|
60
|
+
getRepository(WebAuthCredential).findOne({ where: { credentialId: authenticator.id }, withDeleted: true })
|
|
61
|
+
|
|
62
|
+
async function signinAudits() {
|
|
63
|
+
await flushAudit()
|
|
64
|
+
const rows = await getRepository(AuditAction).find({ where: { kind: 'auth.signin' }, relations: ['actor'], order: { createdAt: 'ASC' } })
|
|
65
|
+
return rows.map(row => [row.outcome, row.code, row.actor?.id ?? null, row.detail?.method])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function context(user: any) {
|
|
69
|
+
return { state: { user, tx: authDataSource().manager } } as any
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
await openAuthDatabase()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await closeAuthDatabase()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
await resetAuthDatabase()
|
|
82
|
+
domain = await getRepository(Domain).save({ name: 'factory', subdomain: 'factory' })
|
|
83
|
+
kim = await seedUser('kim')
|
|
84
|
+
lee = await seedUser('lee')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('what the browser is asked (decision 2)', () => {
|
|
88
|
+
it('registration needs a discoverable passkey and a verified person, and leaves the authenticator to the browser', async () => {
|
|
89
|
+
const options = await passkey.registrationOptions(kim, 'Operato', SITE)
|
|
90
|
+
|
|
91
|
+
expect(options.authenticatorSelection).toEqual({ residentKey: 'required', requireResidentKey: true, userVerification: 'required' })
|
|
92
|
+
expect(options.authenticatorSelection.authenticatorAttachment).toBeUndefined()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('sign-in names no passkey and needs a verified person', async () => {
|
|
96
|
+
const options = await passkey.signinOptions(SITE)
|
|
97
|
+
|
|
98
|
+
expect(options.userVerification).toBe('required')
|
|
99
|
+
expect(options.allowCredentials ?? []).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('registration lists what this person already has, so the browser can say so', async () => {
|
|
103
|
+
const authenticator = await registered(kim)
|
|
104
|
+
const options = await passkey.registrationOptions(kim, 'Operato', SITE)
|
|
105
|
+
|
|
106
|
+
expect(options.excludeCredentials.map(c => c.id)).toEqual([authenticator.id])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('a challenge answers once', () => {
|
|
111
|
+
it('is gone from the session after it is taken', () => {
|
|
112
|
+
const context: any = { session: {} }
|
|
113
|
+
passkey.rememberChallenge(context, 'abc')
|
|
114
|
+
|
|
115
|
+
expect(passkey.takeChallenge(context)).toBe('abc')
|
|
116
|
+
expect(() => passkey.takeChallenge(context)).toThrow('authn verification failed')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('an answer to another challenge is refused', async () => {
|
|
120
|
+
const authenticator = await registered(kim)
|
|
121
|
+
const challenge = await signinChallenge()
|
|
122
|
+
const answer = authenticator.assert(challenge, SITE)
|
|
123
|
+
|
|
124
|
+
await expect(passkey.signinWithPasskey(answer, await signinChallenge(), SITE)).rejects.toThrow('authn verification failed')
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('registration', () => {
|
|
129
|
+
it('keeps the passkey with its name and how it can be reached', async () => {
|
|
130
|
+
const authenticator = await registered(kim, ' macOS · Chrome ')
|
|
131
|
+
const credential = await credentialOf(authenticator)
|
|
132
|
+
|
|
133
|
+
expect(credential.userId).toBe(kim.id)
|
|
134
|
+
expect(credential.name).toBe('macOS · Chrome')
|
|
135
|
+
expect(credential.transports).toEqual(['internal', 'hybrid'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('refuses a passkey made without verifying the person', async () => {
|
|
139
|
+
const authenticator = new SoftwareAuthenticator()
|
|
140
|
+
const { challenge } = await passkey.registrationOptions(kim, 'Operato', SITE)
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
passkey.registerPasskey(kim, authenticator.register(challenge, SITE, { userVerified: false }), 'x', challenge, SITE)
|
|
144
|
+
).rejects.toThrow('authn verification failed')
|
|
145
|
+
expect(await credentialOf(authenticator)).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('signing in (decisions 2 and 4)', () => {
|
|
150
|
+
it('signs the owner in, moves the counter on and notes when it was used', async () => {
|
|
151
|
+
const authenticator = await registered(kim)
|
|
152
|
+
|
|
153
|
+
const answer = await signinWith(authenticator)
|
|
154
|
+
const credential = await credentialOf(authenticator)
|
|
155
|
+
|
|
156
|
+
expect(answer.user.id).toBe(kim.id)
|
|
157
|
+
expect(answer.token).toEqual(expect.any(String))
|
|
158
|
+
expect(credential.counter).toBe(1)
|
|
159
|
+
expect(credential.lastUsedAt).toBeInstanceOf(Date)
|
|
160
|
+
expect(await signinAudits()).toEqual([['ok', null, kim.id, 'passkey']])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('refuses an answer without a verified person, and the audit names the owner', async () => {
|
|
164
|
+
const authenticator = await registered(kim)
|
|
165
|
+
|
|
166
|
+
await expect(signinWith(authenticator, { userVerified: false })).rejects.toThrow('authn verification failed')
|
|
167
|
+
expect((await credentialOf(authenticator)).lastUsedAt).toBeNull()
|
|
168
|
+
expect(await signinAudits()).toEqual([['error', 'authn verification failed', kim.id, 'passkey']])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('refuses a passkey nobody registered, and the audit names nobody', async () => {
|
|
172
|
+
await expect(signinWith(new SoftwareAuthenticator())).rejects.toThrow('authn verification failed')
|
|
173
|
+
expect(await signinAudits()).toEqual([['error', 'authn verification failed', null, 'passkey']])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('refuses a removed passkey', async () => {
|
|
177
|
+
const authenticator = await registered(kim)
|
|
178
|
+
await getRepository(WebAuthCredential).softDelete({ credentialId: authenticator.id })
|
|
179
|
+
|
|
180
|
+
await expect(signinWith(authenticator)).rejects.toThrow('authn verification failed')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('a locked account stays locked - the passkey neither lifts the lock nor counts as a failure', async () => {
|
|
184
|
+
const authenticator = await registered(kim)
|
|
185
|
+
await getRepository(User).update({ id: kim.id }, { status: 'locked', lockedAt: new Date(), failCount: 5 })
|
|
186
|
+
|
|
187
|
+
await expect(signinWith(authenticator)).rejects.toThrow('user-locked')
|
|
188
|
+
|
|
189
|
+
const after = await getRepository(User).findOneBy({ id: kim.id })
|
|
190
|
+
expect([after.status, after.failCount]).toEqual(['locked', 5])
|
|
191
|
+
expect((await credentialOf(authenticator)).lastUsedAt).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('a lock that has run out lifts, the same as for a password', async () => {
|
|
195
|
+
const authenticator = await registered(kim)
|
|
196
|
+
await getRepository(User).update({ id: kim.id }, { status: 'locked', lockedAt: new Date(Date.now() - 24 * 60 * 60 * 1000), failCount: 5 })
|
|
197
|
+
|
|
198
|
+
await signinWith(authenticator)
|
|
199
|
+
|
|
200
|
+
const after = await getRepository(User).findOneBy({ id: kim.id })
|
|
201
|
+
expect([after.status, after.failCount]).toEqual(['activated', 0])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('a deleted or inactive account is refused', async () => {
|
|
205
|
+
const authenticator = await registered(kim)
|
|
206
|
+
|
|
207
|
+
await getRepository(User).update({ id: kim.id }, { status: 'deleted' })
|
|
208
|
+
await expect(signinWith(authenticator)).rejects.toThrow('user-deleted')
|
|
209
|
+
|
|
210
|
+
await getRepository(User).update({ id: kim.id }, { status: 'inactive' })
|
|
211
|
+
await expect(signinWith(authenticator)).rejects.toThrow('user not activated')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('confirming it is the signed-in person', () => {
|
|
216
|
+
async function reverify(user: any, authenticator: SoftwareAuthenticator) {
|
|
217
|
+
const options = await passkey.reverifyOptions(user, SITE)
|
|
218
|
+
return await passkey.reverifyWithPasskey(user, authenticator.assert(options.challenge, SITE), options.challenge, SITE)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
it("answers with the person's own passkey", async () => {
|
|
222
|
+
const authenticator = await registered(kim)
|
|
223
|
+
|
|
224
|
+
await expect(reverify(kim, authenticator)).resolves.toBeUndefined()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("refuses somebody else's passkey", async () => {
|
|
228
|
+
await registered(kim)
|
|
229
|
+
const lees = await registered(lee)
|
|
230
|
+
|
|
231
|
+
await expect(reverify(kim, lees)).rejects.toThrow('authn verification failed')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('has nothing to ask of a person without a passkey', async () => {
|
|
235
|
+
expect(await passkey.reverifyOptions(kim, SITE)).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe("the caller's own passkeys (decision 6)", () => {
|
|
240
|
+
const query = new WebAuthCredentialQuery()
|
|
241
|
+
const mutation = new WebAuthCredentialMutation()
|
|
242
|
+
|
|
243
|
+
it('lists only the caller’s own', async () => {
|
|
244
|
+
const mine = await registered(kim)
|
|
245
|
+
await registered(lee)
|
|
246
|
+
|
|
247
|
+
const list = await query.myWebAuthCredentials(context(kim))
|
|
248
|
+
|
|
249
|
+
expect(list.map(c => c.credentialId)).toEqual([mine.id])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('renames the caller’s own, and answers another person’s id as not found', async () => {
|
|
253
|
+
const credential = await credentialOf(await registered(kim))
|
|
254
|
+
|
|
255
|
+
const renamed = await mutation.updateMyWebAuthCredential(credential.id, ' Phone ', context(kim))
|
|
256
|
+
expect(renamed.name).toBe('Phone')
|
|
257
|
+
|
|
258
|
+
await expect(mutation.updateMyWebAuthCredential(credential.id, 'Mine now', context(lee))).rejects.toThrow('There is no passkey of yours')
|
|
259
|
+
expect((await credentialOf({ id: credential.credentialId } as any)).name).toBe('Phone')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('removes the caller’s own - it signs nobody in afterwards and the removal is audited', async () => {
|
|
263
|
+
const authenticator = await registered(kim)
|
|
264
|
+
const credential = await credentialOf(authenticator)
|
|
265
|
+
|
|
266
|
+
await expect(mutation.deleteMyWebAuthCredential(credential.id, context(lee))).rejects.toThrow('There is no passkey of yours')
|
|
267
|
+
expect((await credentialOf(authenticator)).deletedAt).toBeNull()
|
|
268
|
+
|
|
269
|
+
expect(await mutation.deleteMyWebAuthCredential(credential.id, context(kim))).toBe(true)
|
|
270
|
+
expect((await credentialOf(authenticator)).deletedAt).toBeInstanceOf(Date)
|
|
271
|
+
expect(await query.myWebAuthCredentials(context(kim))).toEqual([])
|
|
272
|
+
await expect(signinWith(authenticator)).rejects.toThrow('authn verification failed')
|
|
273
|
+
|
|
274
|
+
await flushAudit()
|
|
275
|
+
const removals = await getRepository(AuditAction).find({ where: { kind: 'webauthn.remove' }, relations: ['actor'] })
|
|
276
|
+
expect(removals.map(row => row.actor?.id)).toEqual([kim.id])
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('the same authenticator can be registered again after its passkey was removed', async () => {
|
|
280
|
+
const authenticator = await registered(kim)
|
|
281
|
+
await getRepository(WebAuthCredential).softDelete({ credentialId: authenticator.id })
|
|
282
|
+
|
|
283
|
+
const { challenge } = await passkey.registrationOptions(kim, 'Operato', SITE)
|
|
284
|
+
await expect(passkey.registerPasskey(kim, authenticator.register(challenge, SITE), 'again', challenge, SITE)).resolves.toBeTruthy()
|
|
285
|
+
})
|
|
286
|
+
})
|
package/translations/ko.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"error.auth error": "인증 오류. {message}",
|
|
3
|
-
"error.authn verification failed": "
|
|
3
|
+
"error.authn verification failed": "패스키를 확인하지 못했습니다.",
|
|
4
4
|
"error.confirm password not matched": "새 비밀번호와 확인 비밀번호가 일치하지 않습니다.",
|
|
5
5
|
"error.domain mismatch": "인증서의 $t(term.domain)과 현재 $t(term.domain)이 일치하지 않습니다.",
|
|
6
6
|
"error.domain not allowed": "'{subdomain}' 영역은 이 사용자에게 허가되지 않았습니다.",
|
|
@@ -264,5 +264,5 @@
|
|
|
264
264
|
"privilege.allows.audit.query": "모든 사용자의 로그인, 권한 사용과 거절, 역할 변경 기록을 볼 수 있습니다.",
|
|
265
265
|
"task.auth-base.audit-reading.name": "감사 기록 열람",
|
|
266
266
|
"task.auth-base.audit-reading.description": "모든 사용자의 로그인, 권한 사용과 거절, 역할 변경 기록을 읽는다.",
|
|
267
|
-
"error.access-request-task-mismatch": "
|
|
267
|
+
"error.access-request-task-mismatch": "선택한 업무에 접근이 거부된 권한이 포함되어 있지 않습니다."
|
|
268
268
|
}
|