@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
package/src/resolver.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { appendContextual } from '@owlmeans/context'
|
|
2
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
3
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
4
|
+
import type { EntityResolverService, OrgEntityRef } from '@owlmeans/auth-common'
|
|
5
|
+
import { ENTITY_RESOLVER, ENTITY_SLUG_PATTERN } from '@owlmeans/auth-common'
|
|
6
|
+
import { generateWordSlug, nextSlugCandidate } from '@owlmeans/basic-ids'
|
|
7
|
+
import type { OrgEntity, OrgEntityResource } from './types.js'
|
|
8
|
+
import { AUTH_IDENTITY_ORG_ENTITY, MAX_ENTITY_SLUG_ATTEMPTS } from './consts.js'
|
|
9
|
+
|
|
10
|
+
type Context = ServerContext<ServerConfig>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long a resolution is trusted without re-reading the registry.
|
|
14
|
+
*
|
|
15
|
+
* Every authenticated request resolves an entity, so an uncached resolver would put a database
|
|
16
|
+
* round-trip in front of the whole API. The cost of the cache is a rename taking up to this long
|
|
17
|
+
* to be seen by other replicas — which is survivable precisely because the old slug keeps
|
|
18
|
+
* resolving through `formerSlugs` rather than failing.
|
|
19
|
+
*/
|
|
20
|
+
const CACHE_TTL = 30_000
|
|
21
|
+
|
|
22
|
+
/** A stored record as the contract exposes it. Persisted records always carry an id. */
|
|
23
|
+
const toRef = (entity: OrgEntity | null): OrgEntityRef | null =>
|
|
24
|
+
entity == null ? null : {
|
|
25
|
+
id: entity.id!, slug: entity.slug, formerSlugs: entity.formerSlugs, iamKey: entity.iamKey,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CacheEntry {
|
|
29
|
+
entity: OrgEntity | null
|
|
30
|
+
at: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const makeEntityResolverService = (
|
|
34
|
+
alias: string = ENTITY_RESOLVER
|
|
35
|
+
): EntityResolverService => {
|
|
36
|
+
// Keyed by every name a caller might arrive with — id, slug, former slug, iamKey — so a hit
|
|
37
|
+
// costs one lookup regardless of which one it was.
|
|
38
|
+
const cache = new Map<string, CacheEntry>()
|
|
39
|
+
const now = () => Date.now()
|
|
40
|
+
|
|
41
|
+
const remember = (entity: OrgEntity | null, ...keys: string[]): OrgEntity | null => {
|
|
42
|
+
const at = now()
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
if (key !== '') cache.set(key, { entity, at })
|
|
45
|
+
}
|
|
46
|
+
if (entity != null) {
|
|
47
|
+
cache.set(entity.id!, { entity, at })
|
|
48
|
+
cache.set(entity.slug, { entity, at })
|
|
49
|
+
cache.set(entity.iamKey, { entity, at })
|
|
50
|
+
for (const former of entity.formerSlugs ?? []) cache.set(former, { entity, at })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return entity
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const forget = (entity: OrgEntity) => {
|
|
57
|
+
for (const key of [entity.id, entity.slug, entity.iamKey, ...(entity.formerSlugs ?? [])]) {
|
|
58
|
+
if (key != null) cache.delete(key)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resource = (): OrgEntityResource =>
|
|
63
|
+
(service.ctx as Context).resource<OrgEntityResource>(AUTH_IDENTITY_ORG_ENTITY)
|
|
64
|
+
|
|
65
|
+
const load = async (value: string): Promise<OrgEntity | null> => {
|
|
66
|
+
const cached = cache.get(value)
|
|
67
|
+
if (cached != null && now() - cached.at < CACHE_TTL) {
|
|
68
|
+
return cached.entity
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const res = resource()
|
|
72
|
+
// Id first: an id is authoritative, so a slug that happens to look like some other entity's
|
|
73
|
+
// id can never shadow it. A value that is not a record id simply misses. Then the current
|
|
74
|
+
// slug, then the names it has retired, then the frozen key external systems still quote.
|
|
75
|
+
const byId = await res.load(value)
|
|
76
|
+
if (byId != null) return remember(byId, value)
|
|
77
|
+
|
|
78
|
+
const candidates: Criteria<OrgEntity>[] = [
|
|
79
|
+
{ slug: value },
|
|
80
|
+
{ formerSlugs: { $contains: [value] } },
|
|
81
|
+
{ iamKey: value },
|
|
82
|
+
]
|
|
83
|
+
for (const where of candidates) {
|
|
84
|
+
const found = await res.load(where)
|
|
85
|
+
if (found != null) return remember(found, value)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return remember(null, value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const service: EntityResolverService = appendContextual<EntityResolverService>(alias, {
|
|
92
|
+
resolve: async value => toRef(await load(value)),
|
|
93
|
+
|
|
94
|
+
byId: async id => toRef(remember(await resource().load(id), id)),
|
|
95
|
+
|
|
96
|
+
mintSlug: async () => {
|
|
97
|
+
const res = resource()
|
|
98
|
+
// A word slug is picked from 2^22 pairs, so a collision is rare — but "rare" is not "never"
|
|
99
|
+
// once an installation has many organizations, and the numeric suffix keeps the readable
|
|
100
|
+
// name rather than rerolling into an unrelated one.
|
|
101
|
+
for (let attempt = 1; attempt <= MAX_ENTITY_SLUG_ATTEMPTS; ++attempt) {
|
|
102
|
+
const base = generateWordSlug()
|
|
103
|
+
for (let suffix = 1; suffix <= 3; ++suffix) {
|
|
104
|
+
const candidate = nextSlugCandidate(base, suffix)
|
|
105
|
+
const taken = await res.load({
|
|
106
|
+
$or: [{ slug: candidate }, { formerSlugs: { $contains: [candidate] } }],
|
|
107
|
+
})
|
|
108
|
+
if (taken == null) return candidate
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new SyntaxError('entity:slug-exhausted')
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
rename: async (id, slug) => {
|
|
116
|
+
if (!ENTITY_SLUG_PATTERN.test(slug)) {
|
|
117
|
+
throw new SyntaxError(`entity:slug-malformed:${slug}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const res = resource()
|
|
121
|
+
const entity = await res.get(id)
|
|
122
|
+
if (entity.slug === slug) {
|
|
123
|
+
return toRef(entity)!
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A slug another entity has ever answered to cannot be taken: tokens and third-party records
|
|
127
|
+
// quoting it would start resolving to the wrong organization.
|
|
128
|
+
const taken = await res.load({
|
|
129
|
+
$or: [{ slug }, { formerSlugs: { $contains: [slug] } }],
|
|
130
|
+
})
|
|
131
|
+
if (taken != null && taken.id !== id) {
|
|
132
|
+
throw new SyntaxError(`entity:slug-taken:${slug}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
forget(entity)
|
|
136
|
+
const formerSlugs = [...new Set([...(entity.formerSlugs ?? []), entity.slug])]
|
|
137
|
+
.filter(former => former !== slug)
|
|
138
|
+
const updated = await res.update({ ...entity, slug, formerSlugs, updatedAt: new Date() })
|
|
139
|
+
|
|
140
|
+
return toRef(remember(updated))!
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
mintName: async (id, key, mint) => {
|
|
144
|
+
const res = resource()
|
|
145
|
+
const entity = await res.get(id)
|
|
146
|
+
const existing = entity.names?.[key]
|
|
147
|
+
// Read-before-mint is what freezes the name: the second caller gets the first caller's
|
|
148
|
+
// answer, so a rename between the two cannot produce two names for one live resource.
|
|
149
|
+
if (existing != null && existing !== '') {
|
|
150
|
+
return existing
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const minted = mint(toRef(entity)!)
|
|
154
|
+
forget(entity)
|
|
155
|
+
const updated = await res.update({
|
|
156
|
+
...entity, names: { ...entity.names, [key]: minted }, updatedAt: new Date(),
|
|
157
|
+
})
|
|
158
|
+
remember(updated)
|
|
159
|
+
|
|
160
|
+
return updated.names?.[key] ?? minted
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return service
|
|
165
|
+
}
|
package/src/resource.ts
CHANGED
|
@@ -1,23 +1,53 @@
|
|
|
1
1
|
import { makeMongoResource } from '@owlmeans/mongo-resource'
|
|
2
|
-
import type { IdentityAccount, IdentityAccountResource, IdentityProfile, IdentityProfileResource, IdentityCredentials, IdentityCredentialsResource } from './types.js'
|
|
2
|
+
import type { IdentityAccount, IdentityAccountResource, IdentityProfile, IdentityProfileResource, IdentityCredentials, IdentityCredentialsResource, OrgEntity, OrgEntityResource } from './types.js'
|
|
3
3
|
import {
|
|
4
|
-
AUTH_IDENTITY_ACCOUNT, AUTH_IDENTITY_PROFILE, AUTH_IDENTITY_CREDENTIALS,
|
|
5
|
-
AUTH_IDENTITY_ACCOUNT_COLLECTION, AUTH_IDENTITY_PROFILE_COLLECTION, AUTH_IDENTITY_CREDENTIALS_COLLECTION
|
|
4
|
+
AUTH_IDENTITY_ACCOUNT, AUTH_IDENTITY_PROFILE, AUTH_IDENTITY_CREDENTIALS, AUTH_IDENTITY_ORG_ENTITY,
|
|
5
|
+
AUTH_IDENTITY_ACCOUNT_COLLECTION, AUTH_IDENTITY_PROFILE_COLLECTION, AUTH_IDENTITY_CREDENTIALS_COLLECTION,
|
|
6
|
+
AUTH_IDENTITY_ORG_ENTITY_COLLECTION
|
|
6
7
|
} from './consts.js'
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The organization-entity registry.
|
|
11
|
+
*
|
|
12
|
+
* Both unique indexes are load-bearing. `slug` is what a rename writes, so the index is the only
|
|
13
|
+
* thing standing between two organizations and the same public name; `iamKey` is quoted by systems
|
|
14
|
+
* that were told it once and never asked again, so a duplicate there would silently hand one
|
|
15
|
+
* organization another's realm. `formerSlugs` is indexed but NOT unique-scoped as a set — a retired
|
|
16
|
+
* slug must stay findable, and uniqueness against it is enforced when a rename is applied.
|
|
17
|
+
*/
|
|
18
|
+
export const makeOrgEntityResource = (dbAlias?: string): OrgEntityResource => {
|
|
19
|
+
const resource = makeMongoResource<OrgEntity, OrgEntityResource>(
|
|
20
|
+
AUTH_IDENTITY_ORG_ENTITY, dbAlias, undefined, AUTH_IDENTITY_ORG_ENTITY_COLLECTION
|
|
21
|
+
)
|
|
22
|
+
resource.index('slug', { slug: 1 }, { unique: true })
|
|
23
|
+
resource.index('iamKey', { iamKey: 1 }, { unique: true })
|
|
24
|
+
resource.index('formerSlugs', { formerSlugs: 1 })
|
|
25
|
+
return resource
|
|
26
|
+
}
|
|
27
|
+
|
|
8
28
|
export const makeIdentityAccountResource = (dbAlias?: string): IdentityAccountResource => {
|
|
9
29
|
const resource = makeMongoResource<IdentityAccount, IdentityAccountResource>(
|
|
10
|
-
AUTH_IDENTITY_ACCOUNT, dbAlias, undefined,
|
|
30
|
+
AUTH_IDENTITY_ACCOUNT, dbAlias, undefined, AUTH_IDENTITY_ACCOUNT_COLLECTION
|
|
11
31
|
)
|
|
12
32
|
resource.index('credential', { credential: 1 }, { unique: true })
|
|
13
33
|
resource.index('entityId', { entityId: 1 })
|
|
14
34
|
resource.index('secret', { secret: 1 }, { unique: true, sparse: true })
|
|
35
|
+
/**
|
|
36
|
+
* The account's login name — an email, for every provider shipped today.
|
|
37
|
+
*
|
|
38
|
+
* Indexed because `linkProfile` reads it on every registration: a person signing in by a second
|
|
39
|
+
* method must land on the identity they already have, not a new one. NOT unique — the same
|
|
40
|
+
* address legitimately appears twice, once for the platform account and once for an
|
|
41
|
+
* organization's END USER record (`inviteUser`), and those are different people-shaped rows for
|
|
42
|
+
* different audiences. The two are told apart by the profile, never by the account.
|
|
43
|
+
*/
|
|
44
|
+
resource.index('name', { name: 1 })
|
|
15
45
|
return resource
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
export const makeIdentityProfileResource = (dbAlias?: string): IdentityProfileResource => {
|
|
19
49
|
const resource = makeMongoResource<IdentityProfile, IdentityProfileResource>(
|
|
20
|
-
AUTH_IDENTITY_PROFILE, dbAlias, undefined,
|
|
50
|
+
AUTH_IDENTITY_PROFILE, dbAlias, undefined, AUTH_IDENTITY_PROFILE_COLLECTION
|
|
21
51
|
)
|
|
22
52
|
/**
|
|
23
53
|
* The account's mongo id — the ONLY ObjectId reference in the identity trio.
|
|
@@ -34,7 +64,7 @@ export const makeIdentityProfileResource = (dbAlias?: string): IdentityProfileRe
|
|
|
34
64
|
|
|
35
65
|
export const makeIdentityCredentialsResource = (dbAlias?: string): IdentityCredentialsResource => {
|
|
36
66
|
const resource = makeMongoResource<IdentityCredentials, IdentityCredentialsResource>(
|
|
37
|
-
AUTH_IDENTITY_CREDENTIALS, dbAlias, undefined,
|
|
67
|
+
AUTH_IDENTITY_CREDENTIALS, dbAlias, undefined, AUTH_IDENTITY_CREDENTIALS_COLLECTION
|
|
38
68
|
)
|
|
39
69
|
resource.index('provider', { type: 1, userId: 1, credential: 1 }, { unique: true })
|
|
40
70
|
resource.index('profileId', { profileId: 1 })
|
package/src/service.ts
CHANGED
|
@@ -4,8 +4,13 @@ import type { AuthCredentials, AuthPayload, Profile } from '@owlmeans/auth'
|
|
|
4
4
|
import { ALL_SCOPES, AuthRole } from '@owlmeans/auth'
|
|
5
5
|
import type { ProviderProfileDetails } from '@owlmeans/oidc'
|
|
6
6
|
import { createIdOfLength, IdStyle } from '@owlmeans/basic-ids'
|
|
7
|
+
import type { Criteria } from '@owlmeans/resource'
|
|
7
8
|
import type { AccountMeta, IdentityAccountResource, IdentityProfileResource, IdentityCredentialsResource, IdentityLinkingService } from './types.js'
|
|
8
|
-
import {
|
|
9
|
+
import type { IdentityAccount, IdentityCredentials, IdentityProfile } from './types.js'
|
|
10
|
+
import type { OrgEntity, OrgEntityResource } from './types.js'
|
|
11
|
+
import type { EntityResolverService } from '@owlmeans/auth-common'
|
|
12
|
+
import { ENTITY_RESOLVER } from '@owlmeans/auth-common'
|
|
13
|
+
import { AUTH_IDENTITY_ACCOUNT, AUTH_IDENTITY_PROFILE, AUTH_IDENTITY_CREDENTIALS, AUTH_IDENTITY_LINKING, AUTH_IDENTITY_ORG_ENTITY, LOGIN_SERVICE_PREFIX, EXTERNAL_KEY_DELIMITER } from './consts.js'
|
|
9
14
|
|
|
10
15
|
type Context = ServerContext<ServerConfig>
|
|
11
16
|
|
|
@@ -20,23 +25,73 @@ const loginService = (details: ProviderProfileDetails): string =>
|
|
|
20
25
|
[LOGIN_SERVICE_PREFIX, details.type, details.service].join(EXTERNAL_KEY_DELIMITER)
|
|
21
26
|
|
|
22
27
|
export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
28
|
+
/**
|
|
29
|
+
* The wire value for a stored entity id.
|
|
30
|
+
*
|
|
31
|
+
* Records key on the id; everything this service hands back is an auth payload, and payloads
|
|
32
|
+
* carry the slug. An id that no longer resolves yields undefined rather than leaking the raw
|
|
33
|
+
* id onto the wire, where a consumer would mistake it for a slug and compose names from it.
|
|
34
|
+
*/
|
|
35
|
+
const slugOf = async (entityId?: string): Promise<string | undefined> => {
|
|
36
|
+
if (entityId == null || entityId === '') return undefined
|
|
37
|
+
const ctx = service.ctx as Context
|
|
38
|
+
const entity = await ctx.service<EntityResolverService>(ENTITY_RESOLVER).byId(entityId)
|
|
39
|
+
|
|
40
|
+
return entity?.slug
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The platform identity this person already has, if any.
|
|
45
|
+
*
|
|
46
|
+
* One human, one email, one profile — whichever way they sign in. Every provider that reaches
|
|
47
|
+
* `linkProfile` establishes the email first (Google asserts a verified address, the OTP flow
|
|
48
|
+
* proves possession of it, the supervisor key is a full-trust developer credential), so the
|
|
49
|
+
* name is the identity and a second method is a second CREDENTIAL on it, never a second person.
|
|
50
|
+
* Without this the platform minted a whole new organization per method: the same address signed
|
|
51
|
+
* in by Google and by key ended up in two entities, and each could see only the projects the
|
|
52
|
+
* other had not created.
|
|
53
|
+
*
|
|
54
|
+
* Two rows can carry one address, and only one of them is a platform login. `inviteUser`
|
|
55
|
+
* (`@owlmeans/iam-integrated`) writes an END USER record for the same person — the identity the
|
|
56
|
+
* GENERATED application authenticates, deliberately kept apart from the platform credential.
|
|
57
|
+
* Those rows carry no login service, which is what tells the two apart here: this service is
|
|
58
|
+
* the only writer of `credential = "service:{type}:{service}"`, so a profile that has one is a
|
|
59
|
+
* platform login and a profile that has none is somebody's end user.
|
|
60
|
+
*/
|
|
61
|
+
const findPlatformIdentity = async (username: string): Promise<IdentityProfile | null> => {
|
|
62
|
+
const name = username.trim()
|
|
63
|
+
if (name === '') return null
|
|
64
|
+
|
|
65
|
+
const ctx = service.ctx as Context
|
|
66
|
+
const accountResource = ctx.resource<IdentityAccountResource>(AUTH_IDENTITY_ACCOUNT)
|
|
67
|
+
const profileResource = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
68
|
+
|
|
69
|
+
const { items: accounts } = await accountResource.list({ name } as Criteria<IdentityAccount>)
|
|
70
|
+
for (const account of accounts) {
|
|
71
|
+
const { items: profiles } = await profileResource.list({ userId: account.id } as Criteria<IdentityProfile>)
|
|
72
|
+
const platform = profiles.find(profile =>
|
|
73
|
+
profile.credential?.startsWith(`${LOGIN_SERVICE_PREFIX}${EXTERNAL_KEY_DELIMITER}`) === true
|
|
74
|
+
)
|
|
75
|
+
if (platform != null) return platform
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
23
81
|
const service: IdentityLinkingService = appendContextual<IdentityLinkingService>(AUTH_IDENTITY_LINKING, {
|
|
24
82
|
getLinkedProfile: async (details: ProviderProfileDetails): Promise<AuthPayload | null> => {
|
|
25
83
|
const ctx = service.ctx as Context
|
|
26
84
|
const credsResource = ctx.resource<IdentityCredentialsResource>(AUTH_IDENTITY_CREDENTIALS)
|
|
27
85
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
credential: loginService(details),
|
|
33
|
-
}
|
|
86
|
+
const cred = await credsResource.load({
|
|
87
|
+
type: details.type,
|
|
88
|
+
userId: externalKey(details),
|
|
89
|
+
credential: loginService(details),
|
|
34
90
|
})
|
|
35
|
-
const cred = creds[0] ?? null
|
|
36
91
|
if (cred == null) return null
|
|
37
92
|
|
|
38
93
|
const profileResource = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
39
|
-
const profile = await profileResource.load(cred.profileId
|
|
94
|
+
const profile = await profileResource.load({ profileId: cred.profileId })
|
|
40
95
|
if (profile == null) return null
|
|
41
96
|
|
|
42
97
|
return {
|
|
@@ -44,7 +99,7 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
44
99
|
role: profile.role,
|
|
45
100
|
userId: profile.userId ?? profile.profileId,
|
|
46
101
|
profileId: profile.profileId,
|
|
47
|
-
|
|
102
|
+
entitySlug: await slugOf(profile.entityId),
|
|
48
103
|
scopes: profile.scopes,
|
|
49
104
|
}
|
|
50
105
|
},
|
|
@@ -54,20 +109,56 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
54
109
|
const accountResource = ctx.resource<IdentityAccountResource>(AUTH_IDENTITY_ACCOUNT)
|
|
55
110
|
const profileResource = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
56
111
|
const credsResource = ctx.resource<IdentityCredentialsResource>(AUTH_IDENTITY_CREDENTIALS)
|
|
112
|
+
const entityResource = ctx.resource<OrgEntityResource>(AUTH_IDENTITY_ORG_ENTITY)
|
|
113
|
+
const resolver = ctx.service<EntityResolverService>(ENTITY_RESOLVER)
|
|
114
|
+
|
|
115
|
+
// A person the platform already knows is LINKED, not registered again. Everything below
|
|
116
|
+
// this point creates an organization, an account and a profile, and doing that for a second
|
|
117
|
+
// login method is what put one email in two entities.
|
|
118
|
+
const known = meta.force === true ? null : await findPlatformIdentity(meta.username)
|
|
119
|
+
if (known != null) {
|
|
120
|
+
await credsResource.create({
|
|
121
|
+
challenge: '',
|
|
122
|
+
type: details.type,
|
|
123
|
+
userId: externalKey(details),
|
|
124
|
+
profileId: known.profileId,
|
|
125
|
+
credential: loginService(details),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
type: details.type,
|
|
130
|
+
role: known.role,
|
|
131
|
+
userId: known.userId ?? known.profileId,
|
|
132
|
+
profileId: known.profileId,
|
|
133
|
+
entitySlug: await slugOf(known.entityId),
|
|
134
|
+
scopes: known.scopes,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Every account gets its own organization. The entity record is created FIRST because it
|
|
139
|
+
// owns the two values that outlive everything else here: the id the account and its profiles
|
|
140
|
+
// are keyed by, and the frozen `iamKey` that realms and object names are minted from.
|
|
141
|
+
const entity: OrgEntity = await entityResource.create({
|
|
142
|
+
slug: await resolver.mintSlug(),
|
|
143
|
+
formerSlugs: [],
|
|
144
|
+
// Frozen at birth and never recomputed. It cannot be the slug — the slug moves — and it
|
|
145
|
+
// cannot be the record id, which does not exist until this create returns.
|
|
146
|
+
iamKey: createIdOfLength(16, IdStyle.Base58),
|
|
147
|
+
names: {},
|
|
148
|
+
createdAt: new Date(),
|
|
149
|
+
})
|
|
150
|
+
const entityId = entity.id!
|
|
57
151
|
|
|
58
|
-
// Generate a unique local entity slug stored as the account credential.
|
|
59
152
|
let accountId: string | null = null
|
|
60
|
-
let entityId: string | null = null
|
|
61
153
|
for (let i = 0; i < MAX_SLUG_RETRIES; i++) {
|
|
62
154
|
const credential = createIdOfLength(16, IdStyle.Base58)
|
|
63
155
|
try {
|
|
64
156
|
const account = await accountResource.create({
|
|
65
157
|
credential,
|
|
66
158
|
name: meta.username,
|
|
67
|
-
entityId
|
|
159
|
+
entityId,
|
|
68
160
|
})
|
|
69
161
|
accountId = account.id
|
|
70
|
-
entityId = account.credential
|
|
71
162
|
break
|
|
72
163
|
} catch (err: any) {
|
|
73
164
|
if (err?.code === 11000 || err?.message?.includes('duplicate')) continue
|
|
@@ -75,7 +166,7 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
75
166
|
}
|
|
76
167
|
}
|
|
77
168
|
|
|
78
|
-
if (accountId == null
|
|
169
|
+
if (accountId == null) {
|
|
79
170
|
throw new Error('Failed to generate unique account credential after retries')
|
|
80
171
|
}
|
|
81
172
|
|
|
@@ -95,7 +186,7 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
95
186
|
})
|
|
96
187
|
} catch (err: any) {
|
|
97
188
|
if (err?.code === 11000 || err?.message?.includes('duplicate')) {
|
|
98
|
-
profile = await profileResource.get(profileId
|
|
189
|
+
profile = await profileResource.get({ profileId })
|
|
99
190
|
} else {
|
|
100
191
|
throw err
|
|
101
192
|
}
|
|
@@ -114,7 +205,7 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
114
205
|
role: profile.role,
|
|
115
206
|
userId: profile.userId ?? profile.profileId,
|
|
116
207
|
profileId: profile.profileId,
|
|
117
|
-
|
|
208
|
+
entitySlug: await slugOf(profile.entityId),
|
|
118
209
|
scopes: profile.scopes,
|
|
119
210
|
}
|
|
120
211
|
},
|
|
@@ -140,21 +231,33 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
140
231
|
return result
|
|
141
232
|
},
|
|
142
233
|
|
|
234
|
+
unlinkCredentials: async (details: ProviderProfileDetails): Promise<void> => {
|
|
235
|
+
const ctx = service.ctx as Context
|
|
236
|
+
const credsResource = ctx.resource<IdentityCredentialsResource>(AUTH_IDENTITY_CREDENTIALS)
|
|
237
|
+
const cred = await credsResource.load({
|
|
238
|
+
type: details.type,
|
|
239
|
+
userId: externalKey(details),
|
|
240
|
+
credential: loginService(details),
|
|
241
|
+
})
|
|
242
|
+
if (cred?.id == null) return
|
|
243
|
+
await credsResource.delete(cred.id)
|
|
244
|
+
},
|
|
245
|
+
|
|
143
246
|
getOwnerProfiles: async (entityId: string): Promise<Profile[]> => {
|
|
144
247
|
const ctx = service.ctx as Context
|
|
145
248
|
const profileResource = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
146
|
-
const { items: profiles } = await profileResource.list({
|
|
249
|
+
const { items: profiles } = await profileResource.list({ entityId })
|
|
147
250
|
|
|
148
|
-
return profiles.map(p => ({
|
|
251
|
+
return await Promise.all(profiles.map(async p => ({
|
|
149
252
|
id: p.profileId,
|
|
150
253
|
name: p.name,
|
|
151
254
|
credential: p.credential,
|
|
152
|
-
|
|
255
|
+
entitySlug: await slugOf(p.entityId),
|
|
153
256
|
scopes: p.scopes,
|
|
154
257
|
groups: p.groups,
|
|
155
258
|
permissions: p.permissions,
|
|
156
259
|
attributes: p.attributes,
|
|
157
|
-
}))
|
|
260
|
+
})))
|
|
158
261
|
},
|
|
159
262
|
|
|
160
263
|
getOwnerCredentials: async (userId: string, entityId?: string, type?: string): Promise<AuthCredentials | undefined> => {
|
|
@@ -162,19 +265,18 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
162
265
|
const profileResource = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
|
|
163
266
|
const credsResource = ctx.resource<IdentityCredentialsResource>(AUTH_IDENTITY_CREDENTIALS)
|
|
164
267
|
|
|
165
|
-
const profileFilter:
|
|
268
|
+
const profileFilter: Criteria<IdentityProfile> = { userId }
|
|
166
269
|
if (entityId != null) profileFilter.entityId = entityId
|
|
167
|
-
const { items: profiles } = await profileResource.list(profileFilter
|
|
270
|
+
const { items: profiles } = await profileResource.list(profileFilter)
|
|
168
271
|
if (profiles.length === 0) return undefined
|
|
169
272
|
|
|
170
|
-
const credsFilter:
|
|
273
|
+
const credsFilter: Criteria<IdentityCredentials> = {
|
|
171
274
|
profileId: { $in: profiles.map(p => p.profileId) }
|
|
172
275
|
}
|
|
173
276
|
if (type != null) credsFilter.type = type
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
277
|
+
const cred = await credsResource.load(credsFilter)
|
|
278
|
+
if (cred == null) return undefined
|
|
176
279
|
|
|
177
|
-
const cred = creds[0]
|
|
178
280
|
const profile = profiles.find(p => p.profileId === cred.profileId) ?? profiles[0]
|
|
179
281
|
|
|
180
282
|
return {
|
|
@@ -182,7 +284,7 @@ export const makeIdentityLinkingService = (): IdentityLinkingService => {
|
|
|
182
284
|
role: profile.role,
|
|
183
285
|
userId: profile.userId ?? profile.profileId,
|
|
184
286
|
profileId: profile.profileId,
|
|
185
|
-
|
|
287
|
+
entitySlug: await slugOf(profile.entityId),
|
|
186
288
|
scopes: profile.scopes,
|
|
187
289
|
challenge: cred.challenge,
|
|
188
290
|
credential: cred.credential,
|
package/src/types.ts
CHANGED
|
@@ -7,23 +7,29 @@ import type { ProviderProfileDetails } from '@owlmeans/oidc'
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Local account record — one per user.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* `id` is the Mongo resource id; `credential` is the account's stable unique key. `entityId`
|
|
12
|
+
* (inherited from Profile below) holds the organization entity's record id — never its slug.
|
|
11
13
|
*/
|
|
12
|
-
export interface IdentityAccount extends Profile, ResourceRecord {
|
|
14
|
+
export interface IdentityAccount extends Omit<Profile, 'entitySlug'>, ResourceRecord {
|
|
13
15
|
id: string
|
|
14
16
|
credential: string
|
|
17
|
+
/** Stable organization-entity id. Renaming the organization does not touch this. */
|
|
18
|
+
entityId?: string
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Local profile record — ties a user to an entity with a role.
|
|
19
23
|
* Mirrors IAMProfile from @owlmeans/auth backend.
|
|
20
24
|
*/
|
|
21
|
-
export interface IdentityProfile extends Profile, ResourceRecord {
|
|
25
|
+
export interface IdentityProfile extends Omit<Profile, 'entitySlug'>, ResourceRecord {
|
|
22
26
|
id: string
|
|
23
27
|
profileId: string
|
|
24
28
|
userId?: string
|
|
25
29
|
role: AuthRole
|
|
26
30
|
expiresAt?: Date
|
|
31
|
+
/** Stable organization-entity id — the value every per-organization query filters on. */
|
|
32
|
+
entityId?: string
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -36,6 +42,29 @@ export interface IdentityCredentials extends AuthCredentials, ResourceRecord {
|
|
|
36
42
|
profileId: string
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* An organization entity — the record every per-organization store keys on.
|
|
47
|
+
*
|
|
48
|
+
* Its `id` is the stable entity id. The slug is the value users, URLs and tokens see, and it may
|
|
49
|
+
* move; nothing here except `slug` itself changes when it does, which is the entire point of the
|
|
50
|
+
* record existing.
|
|
51
|
+
*/
|
|
52
|
+
export interface OrgEntity extends ResourceRecord {
|
|
53
|
+
id?: string
|
|
54
|
+
slug: string
|
|
55
|
+
formerSlugs?: string[]
|
|
56
|
+
iamKey: string
|
|
57
|
+
/**
|
|
58
|
+
* Names this organization is known by in systems that cannot rename: a Kubernetes namespace, a
|
|
59
|
+
* storage prefix, an IAM realm. Minted once through `mintName` and read back forever after.
|
|
60
|
+
*/
|
|
61
|
+
names?: Record<string, string>
|
|
62
|
+
createdAt: Date
|
|
63
|
+
updatedAt?: Date
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type OrgEntityResource = MongoResource<OrgEntity>
|
|
67
|
+
|
|
39
68
|
export type IdentityAccountResource = MongoResource<IdentityAccount>
|
|
40
69
|
export type IdentityProfileResource = MongoResource<IdentityProfile>
|
|
41
70
|
export type IdentityCredentialsResource = MongoResource<IdentityCredentials>
|
|
@@ -48,6 +77,17 @@ export interface IdentityLinkingService extends InitializedService {
|
|
|
48
77
|
getLinkedProfile: (details: ProviderProfileDetails) => Promise<AuthPayload | null>
|
|
49
78
|
linkProfile: (details: ProviderProfileDetails, meta: AccountMeta) => Promise<AuthPayload>
|
|
50
79
|
linkCredentials: (details: ProviderProfileDetails) => Promise<AuthPayload>
|
|
80
|
+
/**
|
|
81
|
+
* Forget which profile an external login maps to, so the next `linkProfile` establishes it anew.
|
|
82
|
+
*
|
|
83
|
+
* The stored mapping is keyed `{type, userId, credential}` under a unique index, so a caller
|
|
84
|
+
* that has decided the existing mapping is wrong cannot simply write over it — it has to be
|
|
85
|
+
* retired first. That decision belongs to whoever owns the login path (a decorator serving a
|
|
86
|
+
* different population than the platform's own customers, say); the key format belongs here.
|
|
87
|
+
*
|
|
88
|
+
* Idempotent: a login that maps to nothing is already in the state this promises.
|
|
89
|
+
*/
|
|
90
|
+
unlinkCredentials: (details: ProviderProfileDetails) => Promise<void>
|
|
51
91
|
getOwnerProfiles: (entityId: string) => Promise<Profile[]>
|
|
52
92
|
getOwnerCredentials: (userId: string, entityId?: string, type?: string) => Promise<AuthCredentials | undefined>
|
|
53
93
|
}
|