@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.
@@ -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, undefined, AUTH_IDENTITY_ACCOUNT_COLLECTION
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, undefined, AUTH_IDENTITY_PROFILE_COLLECTION
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, undefined, AUTH_IDENTITY_CREDENTIALS_COLLECTION
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 { AUTH_IDENTITY_ACCOUNT, AUTH_IDENTITY_PROFILE, AUTH_IDENTITY_CREDENTIALS, AUTH_IDENTITY_LINKING, LOGIN_SERVICE_PREFIX, EXTERNAL_KEY_DELIMITER } from './consts.js'
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 { items: creds } = await credsResource.list({
29
- criteria: {
30
- type: details.type,
31
- userId: externalKey(details),
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, '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
- entityId: profile.entityId,
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: credential,
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 || entityId == 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, '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
- entityId: profile.entityId,
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({ criteria: { entityId } })
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
- entityId: p.entityId,
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: Record<string, unknown> = { userId }
268
+ const profileFilter: Criteria<IdentityProfile> = { userId }
166
269
  if (entityId != null) profileFilter.entityId = entityId
167
- const { items: profiles } = await profileResource.list(profileFilter as any)
270
+ const { items: profiles } = await profileResource.list(profileFilter)
168
271
  if (profiles.length === 0) return undefined
169
272
 
170
- const credsFilter: Record<string, unknown> = {
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 { items: creds } = await credsResource.list(credsFilter as any)
175
- if (creds.length === 0) return undefined
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
- entityId: profile.entityId,
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
- * id is the Mongo resource id; credential is the stable unique local entity slug.
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
  }