@owlmeans/server-auth-identity 0.1.18-rc.2 → 0.1.18-rc.21
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 +7 -6
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-identity/SKILL.md +165 -80
- package/build/consts.d.ts +3 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +5 -0
- package/build/consts.js.map +1 -1
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +7 -1
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/resolver.d.ts +3 -0
- package/build/resolver.d.ts.map +1 -0
- package/build/resolver.js +133 -0
- package/build/resolver.js.map +1 -0
- package/build/resource.d.ts +11 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +30 -4
- package/build/resource.js.map +1 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +115 -25
- package/build/service.js.map +1 -1
- package/build/types.d.ts +41 -3
- package/build/types.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/consts.ts +6 -0
- package/src/helper.ts +7 -1
- package/src/index.ts +1 -0
- package/src/resolver.ts +165 -0
- package/src/resource.ts +36 -6
- package/src/service.ts +130 -28
- package/src/types.ts +43 -3
- package/tests/linking.spec.ts +202 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AuthRole } from '@owlmeans/auth'
|
|
3
|
+
import { AuthenticationType } from '@owlmeans/auth'
|
|
4
|
+
import { makeIdentityLinkingService } from '../src/service.js'
|
|
5
|
+
import {
|
|
6
|
+
AUTH_IDENTITY_ACCOUNT, AUTH_IDENTITY_CREDENTIALS, AUTH_IDENTITY_ORG_ENTITY,
|
|
7
|
+
AUTH_IDENTITY_PROFILE,
|
|
8
|
+
} from '../src/consts.js'
|
|
9
|
+
import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One human, one email, one platform profile — whichever way they sign in.
|
|
13
|
+
*
|
|
14
|
+
* `linkProfile` used to create an organization, an account and a profile on every registration,
|
|
15
|
+
* and registration is per METHOD. The same address signing in by Google and then by key ended up
|
|
16
|
+
* in two entities that could not see each other's projects: `requireEntity` matched neither, and
|
|
17
|
+
* ownership (`project.createdBy`) named a profile the other identity did not have.
|
|
18
|
+
*
|
|
19
|
+
* The rows are stubbed rather than mocked — the service's whole behaviour here is which record it
|
|
20
|
+
* reads before it writes, so a stub that records writes is the subject, not a stand-in for it.
|
|
21
|
+
*/
|
|
22
|
+
const stubResource = <T extends { id?: string }>(alias: string, seed: T[] = []) => {
|
|
23
|
+
const items: T[] = [...seed]
|
|
24
|
+
let next = seed.length + 1
|
|
25
|
+
|
|
26
|
+
const matches = (record: any, where: any): boolean =>
|
|
27
|
+
where == null || Object.entries(where).every(([key, value]) => record[key] === value)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
alias,
|
|
31
|
+
items,
|
|
32
|
+
list: async (where?: unknown) => ({ items: items.filter(item => matches(item, where)) }),
|
|
33
|
+
load: async (where?: unknown) => items.find(item => matches(item, where)) ?? null,
|
|
34
|
+
get: async (where?: unknown) => {
|
|
35
|
+
const found = items.find(item => matches(item, where))
|
|
36
|
+
if (found == null) throw new Error(`${alias}: not found`)
|
|
37
|
+
return found
|
|
38
|
+
},
|
|
39
|
+
create: async (record: T) => {
|
|
40
|
+
const stored = { ...record, id: record.id ?? `${alias}-${next++}` } as T
|
|
41
|
+
items.push(stored)
|
|
42
|
+
return stored
|
|
43
|
+
},
|
|
44
|
+
delete: async (id: string) => {
|
|
45
|
+
const at = items.findIndex(item => item.id === id)
|
|
46
|
+
if (at < 0) return null
|
|
47
|
+
return items.splice(at, 1)[0]
|
|
48
|
+
},
|
|
49
|
+
registerContext: () => undefined,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const makeCtx = (seed: {
|
|
54
|
+
accounts?: any[], profiles?: any[], credentials?: any[]
|
|
55
|
+
} = {}) => {
|
|
56
|
+
const resources: Record<string, any> = {
|
|
57
|
+
[AUTH_IDENTITY_ACCOUNT]: stubResource('account', seed.accounts),
|
|
58
|
+
[AUTH_IDENTITY_PROFILE]: stubResource('profile', seed.profiles),
|
|
59
|
+
[AUTH_IDENTITY_CREDENTIALS]: stubResource('credential', seed.credentials),
|
|
60
|
+
[AUTH_IDENTITY_ORG_ENTITY]: stubResource('entity'),
|
|
61
|
+
}
|
|
62
|
+
let minted = 0
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
resources,
|
|
66
|
+
resource: (alias: string) => resources[alias],
|
|
67
|
+
service: (alias: string) => {
|
|
68
|
+
if (alias !== ENTITY_RESOLVER) throw new Error(`unexpected service ${alias}`)
|
|
69
|
+
return {
|
|
70
|
+
mintSlug: async () => `slug-${++minted}`,
|
|
71
|
+
byId: async (id: string) => ({ id, slug: `slug-of-${id}` }),
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const details = (type: string, sub: string) => ({
|
|
78
|
+
type, service: type, clientId: type, userId: sub, username: 'person@example.org',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const linkingFor = (ctx: unknown) => {
|
|
82
|
+
const service = makeIdentityLinkingService()
|
|
83
|
+
service.registerContext(ctx as never)
|
|
84
|
+
return service
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('linkProfile', () => {
|
|
88
|
+
test('a first sign-in registers an organization, an account and a profile', async () => {
|
|
89
|
+
const ctx = makeCtx()
|
|
90
|
+
const payload = await linkingFor(ctx).linkProfile(
|
|
91
|
+
details('google-oauth', 'google-sub') as never, { username: 'person@example.org' }
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(ctx.resources[AUTH_IDENTITY_ORG_ENTITY].items).toHaveLength(1)
|
|
95
|
+
expect(ctx.resources[AUTH_IDENTITY_ACCOUNT].items).toHaveLength(1)
|
|
96
|
+
expect(ctx.resources[AUTH_IDENTITY_PROFILE].items).toHaveLength(1)
|
|
97
|
+
expect(payload.profileId).toStartWith('google-oauth:')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('a SECOND method on the same email adds a credential, not an identity', async () => {
|
|
101
|
+
// The whole defect: this used to mint a second organization, and the person's projects
|
|
102
|
+
// became invisible to their other login.
|
|
103
|
+
const ctx = makeCtx()
|
|
104
|
+
const linking = linkingFor(ctx)
|
|
105
|
+
|
|
106
|
+
const first = await linking.linkProfile(
|
|
107
|
+
details('google-oauth', 'google-sub') as never, { username: 'person@example.org' }
|
|
108
|
+
)
|
|
109
|
+
const second = await linking.linkProfile(
|
|
110
|
+
details(AuthenticationType.Supervisor, 'person@example.org') as never,
|
|
111
|
+
{ username: 'person@example.org' }
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(ctx.resources[AUTH_IDENTITY_ORG_ENTITY].items).toHaveLength(1)
|
|
115
|
+
expect(ctx.resources[AUTH_IDENTITY_ACCOUNT].items).toHaveLength(1)
|
|
116
|
+
expect(ctx.resources[AUTH_IDENTITY_PROFILE].items).toHaveLength(1)
|
|
117
|
+
// Two credentials, one per method — which is the point.
|
|
118
|
+
expect(ctx.resources[AUTH_IDENTITY_CREDENTIALS].items).toHaveLength(2)
|
|
119
|
+
expect(ctx.resources[AUTH_IDENTITY_CREDENTIALS].items.map((c: any) => c.type).sort())
|
|
120
|
+
.toEqual(['google-oauth', AuthenticationType.Supervisor].sort())
|
|
121
|
+
|
|
122
|
+
// Same identity, same organization — so the same projects.
|
|
123
|
+
expect(second.profileId).toBe(first.profileId)
|
|
124
|
+
expect(second.entitySlug).toBe(first.entitySlug)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('a different email is a different person', async () => {
|
|
128
|
+
const ctx = makeCtx()
|
|
129
|
+
const linking = linkingFor(ctx)
|
|
130
|
+
|
|
131
|
+
await linking.linkProfile(details('google-oauth', 'a') as never, { username: 'a@example.org' })
|
|
132
|
+
await linking.linkProfile(details('google-oauth', 'b') as never, { username: 'b@example.org' })
|
|
133
|
+
|
|
134
|
+
expect(ctx.resources[AUTH_IDENTITY_ORG_ENTITY].items).toHaveLength(2)
|
|
135
|
+
expect(ctx.resources[AUTH_IDENTITY_PROFILE].items).toHaveLength(2)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("an organization's END USER row is never mistaken for a platform login", async () => {
|
|
139
|
+
// `inviteUser` writes a row for the same person carrying the same address — the identity the
|
|
140
|
+
// GENERATED application authenticates, deliberately separate from the platform credential. It
|
|
141
|
+
// has no login service on it, and that is what tells the two apart.
|
|
142
|
+
const ctx = makeCtx({
|
|
143
|
+
accounts: [{ id: 'acc-end-user', credential: 'x', name: 'person@example.org', entityId: 'ent-1' }],
|
|
144
|
+
profiles: [{
|
|
145
|
+
id: 'prof-end-user', profileId: 'email-otp:acc-end-user', userId: 'acc-end-user',
|
|
146
|
+
role: AuthRole.User, name: 'person@example.org', email: 'person@example.org',
|
|
147
|
+
entityId: 'ent-1', scopes: ['*'],
|
|
148
|
+
}],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const payload = await linkingFor(ctx).linkProfile(
|
|
152
|
+
details('google-oauth', 'google-sub') as never, { username: 'person@example.org' }
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// A new platform identity, not a hijack of the end-user row.
|
|
156
|
+
expect(payload.profileId).toStartWith('google-oauth:')
|
|
157
|
+
expect(ctx.resources[AUTH_IDENTITY_PROFILE].items).toHaveLength(2)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('force still registers a fresh identity', async () => {
|
|
161
|
+
const ctx = makeCtx()
|
|
162
|
+
const linking = linkingFor(ctx)
|
|
163
|
+
|
|
164
|
+
await linking.linkProfile(details('google-oauth', 'a') as never, { username: 'person@example.org' })
|
|
165
|
+
await linking.linkProfile(
|
|
166
|
+
details('google-oauth', 'b') as never, { username: 'person@example.org', force: true }
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
expect(ctx.resources[AUTH_IDENTITY_PROFILE].items).toHaveLength(2)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Forgetting which profile an external login maps to.
|
|
175
|
+
*
|
|
176
|
+
* The mapping is unique on `{type, userId, credential}`, so a caller that has decided the stored
|
|
177
|
+
* one is wrong cannot write over it — it has to be retired first. That is what lets a login path
|
|
178
|
+
* serving a different population than this service's own customers (a target project's end users,
|
|
179
|
+
* say) re-establish an identity of its own for an address that was merged onto a platform one.
|
|
180
|
+
*/
|
|
181
|
+
describe('unlinkCredentials', () => {
|
|
182
|
+
test('the stored mapping is removed, and only that one', async () => {
|
|
183
|
+
const ctx = makeCtx()
|
|
184
|
+
const linking = linkingFor(ctx)
|
|
185
|
+
await linking.linkProfile(details('email-otp', 'sub-1'), { username: 'person@example.org' })
|
|
186
|
+
await linking.linkProfile(details('google', 'sub-2'), { username: 'person@example.org' })
|
|
187
|
+
expect(ctx.resources[AUTH_IDENTITY_CREDENTIALS].items.length).toBe(2)
|
|
188
|
+
|
|
189
|
+
await linking.unlinkCredentials(details('email-otp', 'sub-1'))
|
|
190
|
+
|
|
191
|
+
expect(ctx.resources[AUTH_IDENTITY_CREDENTIALS].items.map((c: any) => c.type))
|
|
192
|
+
.toEqual(['google'])
|
|
193
|
+
// And the login is unlinked as far as every reader is concerned.
|
|
194
|
+
expect(await linking.getLinkedProfile(details('email-otp', 'sub-1'))).toBeNull()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('a login that maps to nothing is already in the state this promises', async () => {
|
|
198
|
+
const linking = linkingFor(makeCtx())
|
|
199
|
+
|
|
200
|
+
expect(await linking.unlinkCredentials(details('email-otp', 'nobody'))).toBeUndefined()
|
|
201
|
+
})
|
|
202
|
+
})
|