@contember/engine-tenant-api 2.2.0-alpha.7 → 2.2.0-alpha.9
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/development/src/TenantContainer.cjs +5 -2
- package/dist/development/src/TenantContainer.cjs.map +1 -1
- package/dist/development/src/TenantContainer.js +5 -2
- package/dist/development/src/TenantContainer.js.map +1 -1
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
- package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
- package/dist/development/src/model/service/IDPSignInManager.cjs +23 -15
- package/dist/development/src/model/service/IDPSignInManager.cjs.map +1 -1
- package/dist/development/src/model/service/IDPSignInManager.js +23 -15
- package/dist/development/src/model/service/IDPSignInManager.js.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs +16 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js +16 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.js +42 -31
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
- package/dist/production/src/TenantContainer.cjs +5 -2
- package/dist/production/src/TenantContainer.cjs.map +1 -1
- package/dist/production/src/TenantContainer.js +5 -2
- package/dist/production/src/TenantContainer.js.map +1 -1
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
- package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
- package/dist/production/src/model/service/IDPSignInManager.cjs +23 -15
- package/dist/production/src/model/service/IDPSignInManager.cjs.map +1 -1
- package/dist/production/src/model/service/IDPSignInManager.js +23 -15
- package/dist/production/src/model/service/IDPSignInManager.js.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs +16 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js +16 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.js +42 -31
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
- package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts +5 -0
- package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts.map +1 -1
- package/dist/tests/cases/unit/apiKey/verifyAndProlongReplica.test.d.ts +2 -0
- package/dist/tests/cases/unit/apiKey/verifyAndProlongReplica.test.d.ts.map +1 -0
- package/dist/tests/src/IdPMock.d.ts.map +1 -1
- package/dist/tests/src/testTenant.d.ts.map +1 -1
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/TenantContainer.d.ts +2 -0
- package/dist/types/TenantContainer.d.ts.map +1 -1
- package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts +5 -3
- package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts.map +1 -1
- package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts +1 -0
- package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts.map +1 -1
- package/dist/types/model/service/IDPSignInManager.d.ts +3 -1
- package/dist/types/model/service/IDPSignInManager.d.ts.map +1 -1
- package/dist/types/model/service/apiKey/ApiKeyManager.d.ts +1 -0
- package/dist/types/model/service/apiKey/ApiKeyManager.d.ts.map +1 -1
- package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts +3 -1
- package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/TenantContainer.ts +7 -2
- package/src/model/commands/apiKey/ProlongApiKeyCommand.ts +10 -11
- package/src/model/commands/authLog/CreateAuthLogEntryCommand.ts +1 -0
- package/src/model/service/IDPSignInManager.ts +26 -14
- package/src/model/service/apiKey/ApiKeyManager.ts +24 -3
- package/src/model/service/idp/IdpSessionRevalidator.ts +43 -28
- package/tests/cases/integration/mocked/signInIdpMutation.test.ts +100 -1
- package/tests/cases/integration/mocked/sql/sqlTransaction.ts +16 -0
- package/tests/cases/unit/apiKey/prolongApiKeyCommand.test.ts +19 -9
- package/tests/cases/unit/apiKey/unpersistedApiKeyManager.test.ts +2 -3
- package/tests/cases/unit/apiKey/verifyAndProlongReplica.test.ts +170 -0
- package/tests/cases/unit/idp/idpSessionRevalidator.test.ts +37 -4
- package/tests/src/IdPMock.ts +2 -0
- package/tests/src/testTenant.ts +1 -0
|
@@ -33,10 +33,11 @@ const CreateIdpSessionCommand = require("../commands/idp/CreateIdpSessionCommand
|
|
|
33
33
|
const Password = require("../dtos/Password.cjs");
|
|
34
34
|
const AuthLogService = require("./AuthLogService.cjs");
|
|
35
35
|
class IDPSignInManager {
|
|
36
|
-
constructor(apiKeyManager, idpRegistry, claimSyncService) {
|
|
36
|
+
constructor(apiKeyManager, idpRegistry, claimSyncService, logger) {
|
|
37
37
|
this.apiKeyManager = apiKeyManager;
|
|
38
38
|
this.idpRegistry = idpRegistry;
|
|
39
39
|
this.claimSyncService = claimSyncService;
|
|
40
|
+
this.logger = logger;
|
|
40
41
|
}
|
|
41
42
|
async signInIDP(dbContext, idpSlug, responseData, expiration, requestInfo, trustForwardedInfo) {
|
|
42
43
|
return dbContext.transaction(async (db) => {
|
|
@@ -96,7 +97,8 @@ class IDPSignInManager {
|
|
|
96
97
|
let claimMappingFailed = false;
|
|
97
98
|
try {
|
|
98
99
|
mapping = ClaimMapping.parseClaimMapping(provider.configuration);
|
|
99
|
-
} catch {
|
|
100
|
+
} catch (e) {
|
|
101
|
+
this.logger.warn(e, { message: "IdP sign-in: claim mapping configuration is invalid", identityProviderId: provider.id });
|
|
100
102
|
claimMappingFailed = true;
|
|
101
103
|
}
|
|
102
104
|
let claimMappingAudit = null;
|
|
@@ -125,19 +127,25 @@ class IDPSignInManager {
|
|
|
125
127
|
if (!hasTokens || db.providers.encryptionEnabled) {
|
|
126
128
|
await db.commandBus.execute(new CreateIdpSessionCommand.CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession));
|
|
127
129
|
} else {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
try {
|
|
131
|
+
await db.commandBus.transaction(
|
|
132
|
+
(bus) => bus.execute(
|
|
133
|
+
new CreateAuthLogEntryCommand.CreateAuthLogEntryCommand({
|
|
134
|
+
type: "idp_session_revalidation_failed",
|
|
135
|
+
invokedById: personRow.identity_id,
|
|
136
|
+
personId: personRow.id,
|
|
137
|
+
identityProviderId: provider.id,
|
|
138
|
+
success: true,
|
|
139
|
+
errorCode: "encryption_disabled",
|
|
140
|
+
ipAddress: requestInfo?.ip,
|
|
141
|
+
userAgent: requestInfo?.userAgent,
|
|
142
|
+
eventData: { apiKeyId }
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
this.logger.error(e, { message: "IdP sign-in: writing the encryption_disabled audit entry failed", identityProviderId: provider.id });
|
|
148
|
+
}
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
return new Response.ResponseOk({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IDPSignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/IDPSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { IdentityProviderBySlugQuery, PersonByIdPQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { IDPHandlerRegistry, IDPResponse, IDPResponseError, IDPValidationError } from './idp/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { InitSignInIdpErrorCode, InitSignInIdpResult, SignInIdpErrorCode } from '../../schema/index.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ApiKeyRequestInfo, CreateIdentityCommand, CreatePersonCommand, CreatePersonIdentityProviderIdentifierCommand } from '../commands/index.js'\nimport { CreateIdpSessionCommand } from '../commands/idp/CreateIdpSessionCommand.js'\nimport { CreateAuthLogEntryCommand } from '../commands/authLog/CreateAuthLogEntryCommand.js'\nimport { TenantRole } from '../authorization/index.js'\nimport { NoPassword } from '../dtos/index.js'\nimport { IdentityProviderRow } from '../queries/idp/types.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { ClaimMappingAudit, IDPClaimSyncService } from './idp/IDPClaimSyncService.js'\nimport { ClaimMapping, parseClaimMapping } from './idp/ClaimMapping.js'\n\nclass IDPSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly idpRegistry: IDPHandlerRegistry,\n\t\tprivate readonly claimSyncService: IDPClaimSyncService,\n\t) {}\n\n\tasync signInIDP(\n\t\tdbContext: DatabaseContext,\n\t\tidpSlug: string,\n\t\tresponseData: unknown,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<IDPSignInManager.SignInIDPResponse> {\n\t\treturn dbContext.transaction(async (db): Promise<IDPSignInManager.SignInIDPResponse> => {\n\t\t\tconst provider = await db.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\t\tif (!provider || provider.disabledAt) {\n\t\t\t\tthrow new Error('provider not found')\n\t\t\t}\n\t\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\t\tlet claim: IDPResponse\n\t\t\ttry {\n\t\t\t\tclaim = await providerService.processResponse(\n\t\t\t\t\tvalidatedConfig,\n\t\t\t\t\tresponseData,\n\t\t\t\t)\n\t\t\t} catch (e) {\n\t\t\t\tif (e instanceof IDPResponseError) {\n\t\t\t\t\treturn new ResponseError('INVALID_IDP_RESPONSE', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tthrow e\n\t\t\t}\n\t\t\tconst resolved = await this.resolvePerson(db, claim, provider)\n\t\t\tconst personRow = resolved.person\n\n\t\t\tif (!personRow) {\n\t\t\t\t// Keep the external error generic (no account-existence leak), but\n\t\t\t\t// record the email-verification gate block distinctly in the audit\n\t\t\t\t// log — it is a takeover-grade event, not an ordinary \"not found\".\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${claim.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: resolved.blockedPersonId,\n\t\t\t\t\t\teventData: resolved.blockedReason ? { reason: resolved.blockedReason } : undefined,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with e-mail ${claim.email} is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// A09 — map IdP claims to project memberships. Returns a `{ before, after }` delta only\n\t\t\t// when something actually changed; the resolver emits the `idp_role_mapped` audit from it\n\t\t\t// (audit stays at the resolver layer).\n\t\t\t//\n\t\t\t// Fail-open on a MALFORMED/out-of-band `claimMapping`: parsing is caught so a bad config can\n\t\t\t// never lock the user out — sign-in proceeds without claim mapping and the resolver emits an\n\t\t\t// `idp_role_mapping_failed` marker. A DB error during the apply itself is NOT caught: it\n\t\t\t// propagates and rolls back the whole sign-in transaction, so a half-applied set of grants is\n\t\t\t// never committed (failing sign-in on an infra error is the safe outcome).\n\t\t\tlet mapping: ClaimMapping | null = null\n\t\t\tlet claimMappingFailed = false\n\t\t\ttry {\n\t\t\t\tmapping = parseClaimMapping(provider.configuration)\n\t\t\t} catch {\n\t\t\t\tclaimMappingFailed = true\n\t\t\t}\n\t\t\tlet claimMappingAudit: ClaimMappingAudit | null = null\n\t\t\tif (mapping) {\n\t\t\t\tconst syncResult = await this.claimSyncService.sync(\n\t\t\t\t\tdb,\n\t\t\t\t\tmapping,\n\t\t\t\t\tclaim,\n\t\t\t\t\t{ id: personRow.identity_id },\n\t\t\t\t\tresolved.isNewPerson === true,\n\t\t\t\t)\n\t\t\t\tclaimMappingAudit = syncResult.audit\n\t\t\t\t// The apply-time safety backstop dropped a configured rule (config-time validation was skipped, or\n\t\t\t\t// the project's ACL drifted since): surface the same fail-open marker as a malformed config, so a\n\t\t\t\t// silently-unapplied grant is visible to the operator. The resolver emits `idp_role_mapping_failed`.\n\t\t\t\tif (syncResult.droppedUnsafeRules) {\n\t\t\t\t\tclaimMappingFailed = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { id: apiKeyId, token: sessionToken } = await this.apiKeyManager.createSessionApiKeyWithId(\n\t\t\t\tdb,\n\t\t\t\tpersonRow.identity_id,\n\t\t\t\texpiration,\n\t\t\t\trequestInfo,\n\t\t\t\ttrustForwardedInfo,\n\t\t\t)\n\n\t\t\t// Bind the federated-session state to this session so it can be re-validated\n\t\t\t// against the IdP later. Present only when the provider supports it and\n\t\t\t// revalidation is enabled on the IdP configuration (A24). Refresh tokens must be\n\t\t\t// encrypted at rest, so when no encryption key is configured we skip persisting a\n\t\t\t// token-bearing session (it would otherwise crash or store secrets in plaintext) —\n\t\t\t// the session then behaves like a plain session with no re-validation.\n\t\t\tif (claim.idpSession) {\n\t\t\t\tconst hasTokens = !!claim.idpSession.tokens && Object.keys(claim.idpSession.tokens).length > 0\n\t\t\t\tif (!hasTokens || db.providers.encryptionEnabled) {\n\t\t\t\t\tawait db.commandBus.execute(new CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession))\n\t\t\t\t} else {\n\t\t\t\t\t// Re-validation is enabled on this IdP but no encryption key is configured, so we\n\t\t\t\t\t// won't store the token-bearing session — it silently degrades to a plain,\n\t\t\t\t\t// non-revalidated session. Audit the downgrade (errorCode `encryption_disabled`)\n\t\t\t\t\t// so the operator can see that a session they expected to be continuously\n\t\t\t\t\t// re-validated is in fact not protected, instead of failing closed at sign-in.\n\t\t\t\t\tawait db.commandBus.execute(\n\t\t\t\t\t\tnew CreateAuthLogEntryCommand({\n\t\t\t\t\t\t\ttype: 'idp_session_revalidation_failed',\n\t\t\t\t\t\t\tinvokedById: personRow.identity_id,\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t\tpersonTokenId: apiKeyId,\n\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\terrorCode: 'encryption_disabled',\n\t\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tidpResponse: claim,\n\t\t\t\tclaimMappingAudit,\n\t\t\t\tclaimMappingFailed,\n\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync initSignInIDP(dbContext: DatabaseContext, idpSlug: string, data: unknown): Promise<IDPSignInManager.InitSignInIDPResponse> {\n\t\tconst provider = await dbContext.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\tif (!provider || provider.disabledAt) {\n\t\t\treturn new ResponseError('PROVIDER_NOT_FOUND', `Identity provider ${idpSlug} not found`)\n\t\t}\n\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\ttry {\n\t\t\tconst initResponse = await providerService.initAuth(validatedConfig, data)\n\n\t\t\tconst publicConfig = providerService.getPublicConfiguration\n\t\t\t\t? providerService.getPublicConfiguration(validatedConfig)\n\t\t\t\t: validatedConfig\n\n\t\t\treturn new ResponseOk({\n\t\t\t\t...initResponse,\n\t\t\t\tidpConfiguration: provider.initReturnsConfig ? publicConfig : null,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message)\n\t\t\t}\n\t\t\tthrow e\n\t\t}\n\t}\n\n\tprivate async resolvePerson(db: DatabaseContext, claim: IDPResponse, provider: IdentityProviderRow): Promise<IDPSignInManager.ResolvePersonResult> {\n\t\tconst personByIdPQuery = new PersonByIdPQuery(provider.id, claim.externalIdentifier)\n\t\tconst personByIdp = await db.queryHandler.fetch(personByIdPQuery)\n\t\tif (personByIdp) {\n\t\t\treturn { person: personByIdp }\n\t\t}\n\n\t\tif (!provider.exclusive) {\n\t\t\tconst personByEmail = typeof claim.email === 'string' && claim.email\n\t\t\t\t? await db.queryHandler.fetch(PersonQuery.byEmail(claim.email))\n\t\t\t\t: null\n\t\t\tif (personByEmail) {\n\t\t\t\t// Auto-linking an IdP identity to a pre-existing local account by\n\t\t\t\t// e-mail is takeover-grade: if the provider returns an unverified\n\t\t\t\t// (or attacker-controlled) e-mail, it could hijack the account. When\n\t\t\t\t// the provider requires a verified e-mail, refuse to link — and to\n\t\t\t\t// sign in — unless the address is asserted verified. `assumeEmailVerified`\n\t\t\t\t// lets a trusted IdP that never emits the claim still satisfy the gate.\n\t\t\t\tconst emailVerified = provider.assumeEmailVerified || claim.emailVerified === true\n\t\t\t\tif (provider.requireVerifiedEmail && !emailVerified) {\n\t\t\t\t\treturn { person: null, blockedReason: 'idp_email_unverified', blockedPersonId: personByEmail.id }\n\t\t\t\t}\n\t\t\t\tawait this.saveIdpIdentifier(db, provider, claim, personByEmail)\n\t\t\t\treturn { person: personByEmail }\n\t\t\t}\n\t\t}\n\n\t\tif (provider.autoSignUp) {\n\t\t\tconst signedUpPerson = await this.signUp(db, claim, provider)\n\t\t\tawait this.saveIdpIdentifier(db, provider, claim, signedUpPerson)\n\t\t\treturn { person: signedUpPerson, isNewPerson: true }\n\t\t}\n\n\t\treturn { person: null }\n\t}\n\n\tprivate async signUp(db: DatabaseContext, { email, name, externalIdentifier }: IDPResponse, provider: IdentityProviderRow): Promise<PersonRow> {\n\t\tconst roles = [TenantRole.PERSON]\n\t\tconst identityId = await db.commandBus.execute(new CreateIdentityCommand(roles))\n\t\tconst newPerson = await db.commandBus.execute(\n\t\t\tnew CreatePersonCommand({\n\t\t\t\tidentityId,\n\t\t\t\temail: provider.exclusive ? undefined : email,\n\t\t\t\tpassword: NoPassword,\n\t\t\t\tname: name ?? email?.split('@')[0] ?? externalIdentifier,\n\t\t\t\tidpOnly: provider.exclusive,\n\t\t\t}),\n\t\t)\n\n\t\treturn {\n\t\t\t...newPerson,\n\t\t\troles,\n\t\t}\n\t}\n\n\tprivate async saveIdpIdentifier(db: DatabaseContext, provider: IdentityProviderRow, claim: IDPResponse, person: PersonRow) {\n\t\tawait db.commandBus.execute(new CreatePersonIdentityProviderIdentifierCommand(provider.id, person.id, claim.externalIdentifier))\n\t}\n}\n\nnamespace IDPSignInManager {\n\texport type ResolvePersonResult = {\n\t\treadonly person: PersonRow | null\n\t\treadonly blockedReason?: 'idp_email_unverified'\n\t\treadonly blockedPersonId?: string\n\t\t/** True when the person row was just created by auto-sign-up (drives the `sticky` claim-mapping policy). */\n\t\treadonly isNewPerson?: boolean\n\t}\n\n\texport type InitSignInIDPResponse = Response<InitSignInIdpResult, InitSignInIdpErrorCode>\n\n\tinterface SignInIDPResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\treadonly idpResponse?: Record<string, unknown>\n\t\t/** Identity provider the sign-in went through (for the resolver's audit). */\n\t\treadonly identityProviderId?: string\n\t\t/** Claim-mapping (A09) before/after delta — present only when a mapping changed grants. */\n\t\treadonly claimMappingAudit?: ClaimMappingAudit | null\n\t\t/** True when claim mapping failed and was skipped (fail-open) — drives the `idp_role_mapping_failed` audit. */\n\t\treadonly claimMappingFailed?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\n\texport type SignInIDPResponse = Response<SignInIDPResult, SignInIdpErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n}\n\nexport { IDPSignInManager }\n"],"names":["IdentityProviderBySlugQuery","IDPResponseError","ResponseError","AuthLogService","IDPValidationError","parseClaimMapping","CreateIdpSessionCommand","CreateAuthLogEntryCommand","ResponseOk","PersonByIdPQuery","PersonQuery","TenantRole","CreateIdentityCommand","CreatePersonCommand","NoPassword","CreatePersonIdentityProviderIdentifierCommand"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"IDPSignInManager.cjs","sources":["../../../../../../packages/engine-tenant-api/src/model/service/IDPSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { IdentityProviderBySlugQuery, PersonByIdPQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { IDPHandlerRegistry, IDPResponse, IDPResponseError, IDPValidationError } from './idp/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { InitSignInIdpErrorCode, InitSignInIdpResult, SignInIdpErrorCode } from '../../schema/index.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ApiKeyRequestInfo, CreateIdentityCommand, CreatePersonCommand, CreatePersonIdentityProviderIdentifierCommand } from '../commands/index.js'\nimport { CreateIdpSessionCommand } from '../commands/idp/CreateIdpSessionCommand.js'\nimport { CreateAuthLogEntryCommand } from '../commands/authLog/CreateAuthLogEntryCommand.js'\nimport { TenantRole } from '../authorization/index.js'\nimport { NoPassword } from '../dtos/index.js'\nimport { IdentityProviderRow } from '../queries/idp/types.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { ClaimMappingAudit, IDPClaimSyncService } from './idp/IDPClaimSyncService.js'\nimport { ClaimMapping, parseClaimMapping } from './idp/ClaimMapping.js'\nimport { Logger } from '@contember/logger'\n\nclass IDPSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly idpRegistry: IDPHandlerRegistry,\n\t\tprivate readonly claimSyncService: IDPClaimSyncService,\n\t\tprivate readonly logger: Logger,\n\t) {}\n\n\tasync signInIDP(\n\t\tdbContext: DatabaseContext,\n\t\tidpSlug: string,\n\t\tresponseData: unknown,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<IDPSignInManager.SignInIDPResponse> {\n\t\treturn dbContext.transaction(async (db): Promise<IDPSignInManager.SignInIDPResponse> => {\n\t\t\tconst provider = await db.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\t\tif (!provider || provider.disabledAt) {\n\t\t\t\tthrow new Error('provider not found')\n\t\t\t}\n\t\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\t\tlet claim: IDPResponse\n\t\t\ttry {\n\t\t\t\tclaim = await providerService.processResponse(\n\t\t\t\t\tvalidatedConfig,\n\t\t\t\t\tresponseData,\n\t\t\t\t)\n\t\t\t} catch (e) {\n\t\t\t\tif (e instanceof IDPResponseError) {\n\t\t\t\t\treturn new ResponseError('INVALID_IDP_RESPONSE', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tthrow e\n\t\t\t}\n\t\t\tconst resolved = await this.resolvePerson(db, claim, provider)\n\t\t\tconst personRow = resolved.person\n\n\t\t\tif (!personRow) {\n\t\t\t\t// Keep the external error generic (no account-existence leak), but\n\t\t\t\t// record the email-verification gate block distinctly in the audit\n\t\t\t\t// log — it is a takeover-grade event, not an ordinary \"not found\".\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${claim.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: resolved.blockedPersonId,\n\t\t\t\t\t\teventData: resolved.blockedReason ? { reason: resolved.blockedReason } : undefined,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with e-mail ${claim.email} is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// A09 — map IdP claims to project memberships. Returns a `{ before, after }` delta only\n\t\t\t// when something actually changed; the resolver emits the `idp_role_mapped` audit from it\n\t\t\t// (audit stays at the resolver layer).\n\t\t\t//\n\t\t\t// Fail-open on a MALFORMED/out-of-band `claimMapping`: parsing is caught so a bad config can\n\t\t\t// never lock the user out — sign-in proceeds without claim mapping and the resolver emits an\n\t\t\t// `idp_role_mapping_failed` marker. A DB error during the apply itself is NOT caught: it\n\t\t\t// propagates and rolls back the whole sign-in transaction, so a half-applied set of grants is\n\t\t\t// never committed (failing sign-in on an infra error is the safe outcome).\n\t\t\tlet mapping: ClaimMapping | null = null\n\t\t\tlet claimMappingFailed = false\n\t\t\ttry {\n\t\t\t\tmapping = parseClaimMapping(provider.configuration)\n\t\t\t} catch (e) {\n\t\t\t\tthis.logger.warn(e, { message: 'IdP sign-in: claim mapping configuration is invalid', identityProviderId: provider.id })\n\t\t\t\tclaimMappingFailed = true\n\t\t\t}\n\t\t\tlet claimMappingAudit: ClaimMappingAudit | null = null\n\t\t\tif (mapping) {\n\t\t\t\tconst syncResult = await this.claimSyncService.sync(\n\t\t\t\t\tdb,\n\t\t\t\t\tmapping,\n\t\t\t\t\tclaim,\n\t\t\t\t\t{ id: personRow.identity_id },\n\t\t\t\t\tresolved.isNewPerson === true,\n\t\t\t\t)\n\t\t\t\tclaimMappingAudit = syncResult.audit\n\t\t\t\t// The apply-time safety backstop dropped a configured rule (config-time validation was skipped, or\n\t\t\t\t// the project's ACL drifted since): surface the same fail-open marker as a malformed config, so a\n\t\t\t\t// silently-unapplied grant is visible to the operator. The resolver emits `idp_role_mapping_failed`.\n\t\t\t\tif (syncResult.droppedUnsafeRules) {\n\t\t\t\t\tclaimMappingFailed = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { id: apiKeyId, token: sessionToken } = await this.apiKeyManager.createSessionApiKeyWithId(\n\t\t\t\tdb,\n\t\t\t\tpersonRow.identity_id,\n\t\t\t\texpiration,\n\t\t\t\trequestInfo,\n\t\t\t\ttrustForwardedInfo,\n\t\t\t)\n\n\t\t\t// Bind the federated-session state to this session so it can be re-validated\n\t\t\t// against the IdP later. Present only when the provider supports it and\n\t\t\t// revalidation is enabled on the IdP configuration (A24). Refresh tokens must be\n\t\t\t// encrypted at rest, so when no encryption key is configured we skip persisting a\n\t\t\t// token-bearing session (it would otherwise crash or store secrets in plaintext) —\n\t\t\t// the session then behaves like a plain session with no re-validation.\n\t\t\tif (claim.idpSession) {\n\t\t\t\tconst hasTokens = !!claim.idpSession.tokens && Object.keys(claim.idpSession.tokens).length > 0\n\t\t\t\tif (!hasTokens || db.providers.encryptionEnabled) {\n\t\t\t\t\tawait db.commandBus.execute(new CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession))\n\t\t\t\t} else {\n\t\t\t\t\t// Re-validation is enabled on this IdP but no encryption key is configured, so we\n\t\t\t\t\t// won't store the token-bearing session — it silently degrades to a plain,\n\t\t\t\t\t// non-revalidated session. Audit the downgrade (errorCode `encryption_disabled`)\n\t\t\t\t\t// so the operator can see that a session they expected to be continuously\n\t\t\t\t\t// re-validated is in fact not protected, instead of failing closed at sign-in.\n\t\t\t\t\t// Best-effort, in a savepoint: a failed insert would otherwise abort the whole\n\t\t\t\t\t// sign-in transaction, and a lost audit row must not block the sign-in.\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait db.commandBus.transaction(bus =>\n\t\t\t\t\t\t\tbus.execute(\n\t\t\t\t\t\t\t\tnew CreateAuthLogEntryCommand({\n\t\t\t\t\t\t\t\t\ttype: 'idp_session_revalidation_failed',\n\t\t\t\t\t\t\t\t\tinvokedById: personRow.identity_id,\n\t\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\t\terrorCode: 'encryption_disabled',\n\t\t\t\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\t\t\t\teventData: { apiKeyId },\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tthis.logger.error(e, { message: 'IdP sign-in: writing the encryption_disabled audit entry failed', identityProviderId: provider.id })\n\t\t\t\t\t\t// the savepoint is rolled back, the sign-in transaction stays usable\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tidpResponse: claim,\n\t\t\t\tclaimMappingAudit,\n\t\t\t\tclaimMappingFailed,\n\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync initSignInIDP(dbContext: DatabaseContext, idpSlug: string, data: unknown): Promise<IDPSignInManager.InitSignInIDPResponse> {\n\t\tconst provider = await dbContext.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\tif (!provider || provider.disabledAt) {\n\t\t\treturn new ResponseError('PROVIDER_NOT_FOUND', `Identity provider ${idpSlug} not found`)\n\t\t}\n\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\ttry {\n\t\t\tconst initResponse = await providerService.initAuth(validatedConfig, data)\n\n\t\t\tconst publicConfig = providerService.getPublicConfiguration\n\t\t\t\t? providerService.getPublicConfiguration(validatedConfig)\n\t\t\t\t: validatedConfig\n\n\t\t\treturn new ResponseOk({\n\t\t\t\t...initResponse,\n\t\t\t\tidpConfiguration: provider.initReturnsConfig ? publicConfig : null,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message)\n\t\t\t}\n\t\t\tthrow e\n\t\t}\n\t}\n\n\tprivate async resolvePerson(db: DatabaseContext, claim: IDPResponse, provider: IdentityProviderRow): Promise<IDPSignInManager.ResolvePersonResult> {\n\t\tconst personByIdPQuery = new PersonByIdPQuery(provider.id, claim.externalIdentifier)\n\t\tconst personByIdp = await db.queryHandler.fetch(personByIdPQuery)\n\t\tif (personByIdp) {\n\t\t\treturn { person: personByIdp }\n\t\t}\n\n\t\tif (!provider.exclusive) {\n\t\t\tconst personByEmail = typeof claim.email === 'string' && claim.email\n\t\t\t\t? await db.queryHandler.fetch(PersonQuery.byEmail(claim.email))\n\t\t\t\t: null\n\t\t\tif (personByEmail) {\n\t\t\t\t// Auto-linking an IdP identity to a pre-existing local account by\n\t\t\t\t// e-mail is takeover-grade: if the provider returns an unverified\n\t\t\t\t// (or attacker-controlled) e-mail, it could hijack the account. When\n\t\t\t\t// the provider requires a verified e-mail, refuse to link — and to\n\t\t\t\t// sign in — unless the address is asserted verified. `assumeEmailVerified`\n\t\t\t\t// lets a trusted IdP that never emits the claim still satisfy the gate.\n\t\t\t\tconst emailVerified = provider.assumeEmailVerified || claim.emailVerified === true\n\t\t\t\tif (provider.requireVerifiedEmail && !emailVerified) {\n\t\t\t\t\treturn { person: null, blockedReason: 'idp_email_unverified', blockedPersonId: personByEmail.id }\n\t\t\t\t}\n\t\t\t\tawait this.saveIdpIdentifier(db, provider, claim, personByEmail)\n\t\t\t\treturn { person: personByEmail }\n\t\t\t}\n\t\t}\n\n\t\tif (provider.autoSignUp) {\n\t\t\tconst signedUpPerson = await this.signUp(db, claim, provider)\n\t\t\tawait this.saveIdpIdentifier(db, provider, claim, signedUpPerson)\n\t\t\treturn { person: signedUpPerson, isNewPerson: true }\n\t\t}\n\n\t\treturn { person: null }\n\t}\n\n\tprivate async signUp(db: DatabaseContext, { email, name, externalIdentifier }: IDPResponse, provider: IdentityProviderRow): Promise<PersonRow> {\n\t\tconst roles = [TenantRole.PERSON]\n\t\tconst identityId = await db.commandBus.execute(new CreateIdentityCommand(roles))\n\t\tconst newPerson = await db.commandBus.execute(\n\t\t\tnew CreatePersonCommand({\n\t\t\t\tidentityId,\n\t\t\t\temail: provider.exclusive ? undefined : email,\n\t\t\t\tpassword: NoPassword,\n\t\t\t\tname: name ?? email?.split('@')[0] ?? externalIdentifier,\n\t\t\t\tidpOnly: provider.exclusive,\n\t\t\t}),\n\t\t)\n\n\t\treturn {\n\t\t\t...newPerson,\n\t\t\troles,\n\t\t}\n\t}\n\n\tprivate async saveIdpIdentifier(db: DatabaseContext, provider: IdentityProviderRow, claim: IDPResponse, person: PersonRow) {\n\t\tawait db.commandBus.execute(new CreatePersonIdentityProviderIdentifierCommand(provider.id, person.id, claim.externalIdentifier))\n\t}\n}\n\nnamespace IDPSignInManager {\n\texport type ResolvePersonResult = {\n\t\treadonly person: PersonRow | null\n\t\treadonly blockedReason?: 'idp_email_unverified'\n\t\treadonly blockedPersonId?: string\n\t\t/** True when the person row was just created by auto-sign-up (drives the `sticky` claim-mapping policy). */\n\t\treadonly isNewPerson?: boolean\n\t}\n\n\texport type InitSignInIDPResponse = Response<InitSignInIdpResult, InitSignInIdpErrorCode>\n\n\tinterface SignInIDPResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\treadonly idpResponse?: Record<string, unknown>\n\t\t/** Identity provider the sign-in went through (for the resolver's audit). */\n\t\treadonly identityProviderId?: string\n\t\t/** Claim-mapping (A09) before/after delta — present only when a mapping changed grants. */\n\t\treadonly claimMappingAudit?: ClaimMappingAudit | null\n\t\t/** True when claim mapping failed and was skipped (fail-open) — drives the `idp_role_mapping_failed` audit. */\n\t\treadonly claimMappingFailed?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\n\texport type SignInIDPResponse = Response<SignInIDPResult, SignInIdpErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n}\n\nexport { IDPSignInManager }\n"],"names":["IdentityProviderBySlugQuery","IDPResponseError","ResponseError","AuthLogService","IDPValidationError","parseClaimMapping","CreateIdpSessionCommand","CreateAuthLogEntryCommand","ResponseOk","PersonByIdPQuery","PersonQuery","TenantRole","CreateIdentityCommand","CreatePersonCommand","NoPassword","CreatePersonIdentityProviderIdentifierCommand"],"mappings":"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"}
|
|
@@ -31,10 +31,11 @@ import { CreateIdpSessionCommand } from "../commands/idp/CreateIdpSessionCommand
|
|
|
31
31
|
import { NoPassword } from "../dtos/Password.js";
|
|
32
32
|
import { AuthLogService } from "./AuthLogService.js";
|
|
33
33
|
class IDPSignInManager {
|
|
34
|
-
constructor(apiKeyManager, idpRegistry, claimSyncService) {
|
|
34
|
+
constructor(apiKeyManager, idpRegistry, claimSyncService, logger) {
|
|
35
35
|
this.apiKeyManager = apiKeyManager;
|
|
36
36
|
this.idpRegistry = idpRegistry;
|
|
37
37
|
this.claimSyncService = claimSyncService;
|
|
38
|
+
this.logger = logger;
|
|
38
39
|
}
|
|
39
40
|
async signInIDP(dbContext, idpSlug, responseData, expiration, requestInfo, trustForwardedInfo) {
|
|
40
41
|
return dbContext.transaction(async (db) => {
|
|
@@ -94,7 +95,8 @@ class IDPSignInManager {
|
|
|
94
95
|
let claimMappingFailed = false;
|
|
95
96
|
try {
|
|
96
97
|
mapping = parseClaimMapping(provider.configuration);
|
|
97
|
-
} catch {
|
|
98
|
+
} catch (e) {
|
|
99
|
+
this.logger.warn(e, { message: "IdP sign-in: claim mapping configuration is invalid", identityProviderId: provider.id });
|
|
98
100
|
claimMappingFailed = true;
|
|
99
101
|
}
|
|
100
102
|
let claimMappingAudit = null;
|
|
@@ -123,19 +125,25 @@ class IDPSignInManager {
|
|
|
123
125
|
if (!hasTokens || db.providers.encryptionEnabled) {
|
|
124
126
|
await db.commandBus.execute(new CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession));
|
|
125
127
|
} else {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
try {
|
|
129
|
+
await db.commandBus.transaction(
|
|
130
|
+
(bus) => bus.execute(
|
|
131
|
+
new CreateAuthLogEntryCommand({
|
|
132
|
+
type: "idp_session_revalidation_failed",
|
|
133
|
+
invokedById: personRow.identity_id,
|
|
134
|
+
personId: personRow.id,
|
|
135
|
+
identityProviderId: provider.id,
|
|
136
|
+
success: true,
|
|
137
|
+
errorCode: "encryption_disabled",
|
|
138
|
+
ipAddress: requestInfo?.ip,
|
|
139
|
+
userAgent: requestInfo?.userAgent,
|
|
140
|
+
eventData: { apiKeyId }
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
this.logger.error(e, { message: "IdP sign-in: writing the encryption_disabled audit entry failed", identityProviderId: provider.id });
|
|
146
|
+
}
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
149
|
return new ResponseOk({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IDPSignInManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/IDPSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { IdentityProviderBySlugQuery, PersonByIdPQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { IDPHandlerRegistry, IDPResponse, IDPResponseError, IDPValidationError } from './idp/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { InitSignInIdpErrorCode, InitSignInIdpResult, SignInIdpErrorCode } from '../../schema/index.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ApiKeyRequestInfo, CreateIdentityCommand, CreatePersonCommand, CreatePersonIdentityProviderIdentifierCommand } from '../commands/index.js'\nimport { CreateIdpSessionCommand } from '../commands/idp/CreateIdpSessionCommand.js'\nimport { CreateAuthLogEntryCommand } from '../commands/authLog/CreateAuthLogEntryCommand.js'\nimport { TenantRole } from '../authorization/index.js'\nimport { NoPassword } from '../dtos/index.js'\nimport { IdentityProviderRow } from '../queries/idp/types.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { ClaimMappingAudit, IDPClaimSyncService } from './idp/IDPClaimSyncService.js'\nimport { ClaimMapping, parseClaimMapping } from './idp/ClaimMapping.js'\n\nclass IDPSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly idpRegistry: IDPHandlerRegistry,\n\t\tprivate readonly claimSyncService: IDPClaimSyncService,\n\t) {}\n\n\tasync signInIDP(\n\t\tdbContext: DatabaseContext,\n\t\tidpSlug: string,\n\t\tresponseData: unknown,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<IDPSignInManager.SignInIDPResponse> {\n\t\treturn dbContext.transaction(async (db): Promise<IDPSignInManager.SignInIDPResponse> => {\n\t\t\tconst provider = await db.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\t\tif (!provider || provider.disabledAt) {\n\t\t\t\tthrow new Error('provider not found')\n\t\t\t}\n\t\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\t\tlet claim: IDPResponse\n\t\t\ttry {\n\t\t\t\tclaim = await providerService.processResponse(\n\t\t\t\t\tvalidatedConfig,\n\t\t\t\t\tresponseData,\n\t\t\t\t)\n\t\t\t} catch (e) {\n\t\t\t\tif (e instanceof IDPResponseError) {\n\t\t\t\t\treturn new ResponseError('INVALID_IDP_RESPONSE', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tthrow e\n\t\t\t}\n\t\t\tconst resolved = await this.resolvePerson(db, claim, provider)\n\t\t\tconst personRow = resolved.person\n\n\t\t\tif (!personRow) {\n\t\t\t\t// Keep the external error generic (no account-existence leak), but\n\t\t\t\t// record the email-verification gate block distinctly in the audit\n\t\t\t\t// log — it is a takeover-grade event, not an ordinary \"not found\".\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${claim.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: resolved.blockedPersonId,\n\t\t\t\t\t\teventData: resolved.blockedReason ? { reason: resolved.blockedReason } : undefined,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with e-mail ${claim.email} is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// A09 — map IdP claims to project memberships. Returns a `{ before, after }` delta only\n\t\t\t// when something actually changed; the resolver emits the `idp_role_mapped` audit from it\n\t\t\t// (audit stays at the resolver layer).\n\t\t\t//\n\t\t\t// Fail-open on a MALFORMED/out-of-band `claimMapping`: parsing is caught so a bad config can\n\t\t\t// never lock the user out — sign-in proceeds without claim mapping and the resolver emits an\n\t\t\t// `idp_role_mapping_failed` marker. A DB error during the apply itself is NOT caught: it\n\t\t\t// propagates and rolls back the whole sign-in transaction, so a half-applied set of grants is\n\t\t\t// never committed (failing sign-in on an infra error is the safe outcome).\n\t\t\tlet mapping: ClaimMapping | null = null\n\t\t\tlet claimMappingFailed = false\n\t\t\ttry {\n\t\t\t\tmapping = parseClaimMapping(provider.configuration)\n\t\t\t} catch {\n\t\t\t\tclaimMappingFailed = true\n\t\t\t}\n\t\t\tlet claimMappingAudit: ClaimMappingAudit | null = null\n\t\t\tif (mapping) {\n\t\t\t\tconst syncResult = await this.claimSyncService.sync(\n\t\t\t\t\tdb,\n\t\t\t\t\tmapping,\n\t\t\t\t\tclaim,\n\t\t\t\t\t{ id: personRow.identity_id },\n\t\t\t\t\tresolved.isNewPerson === true,\n\t\t\t\t)\n\t\t\t\tclaimMappingAudit = syncResult.audit\n\t\t\t\t// The apply-time safety backstop dropped a configured rule (config-time validation was skipped, or\n\t\t\t\t// the project's ACL drifted since): surface the same fail-open marker as a malformed config, so a\n\t\t\t\t// silently-unapplied grant is visible to the operator. The resolver emits `idp_role_mapping_failed`.\n\t\t\t\tif (syncResult.droppedUnsafeRules) {\n\t\t\t\t\tclaimMappingFailed = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { id: apiKeyId, token: sessionToken } = await this.apiKeyManager.createSessionApiKeyWithId(\n\t\t\t\tdb,\n\t\t\t\tpersonRow.identity_id,\n\t\t\t\texpiration,\n\t\t\t\trequestInfo,\n\t\t\t\ttrustForwardedInfo,\n\t\t\t)\n\n\t\t\t// Bind the federated-session state to this session so it can be re-validated\n\t\t\t// against the IdP later. Present only when the provider supports it and\n\t\t\t// revalidation is enabled on the IdP configuration (A24). Refresh tokens must be\n\t\t\t// encrypted at rest, so when no encryption key is configured we skip persisting a\n\t\t\t// token-bearing session (it would otherwise crash or store secrets in plaintext) —\n\t\t\t// the session then behaves like a plain session with no re-validation.\n\t\t\tif (claim.idpSession) {\n\t\t\t\tconst hasTokens = !!claim.idpSession.tokens && Object.keys(claim.idpSession.tokens).length > 0\n\t\t\t\tif (!hasTokens || db.providers.encryptionEnabled) {\n\t\t\t\t\tawait db.commandBus.execute(new CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession))\n\t\t\t\t} else {\n\t\t\t\t\t// Re-validation is enabled on this IdP but no encryption key is configured, so we\n\t\t\t\t\t// won't store the token-bearing session — it silently degrades to a plain,\n\t\t\t\t\t// non-revalidated session. Audit the downgrade (errorCode `encryption_disabled`)\n\t\t\t\t\t// so the operator can see that a session they expected to be continuously\n\t\t\t\t\t// re-validated is in fact not protected, instead of failing closed at sign-in.\n\t\t\t\t\tawait db.commandBus.execute(\n\t\t\t\t\t\tnew CreateAuthLogEntryCommand({\n\t\t\t\t\t\t\ttype: 'idp_session_revalidation_failed',\n\t\t\t\t\t\t\tinvokedById: personRow.identity_id,\n\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t\tpersonTokenId: apiKeyId,\n\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\terrorCode: 'encryption_disabled',\n\t\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\t}),\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tidpResponse: claim,\n\t\t\t\tclaimMappingAudit,\n\t\t\t\tclaimMappingFailed,\n\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync initSignInIDP(dbContext: DatabaseContext, idpSlug: string, data: unknown): Promise<IDPSignInManager.InitSignInIDPResponse> {\n\t\tconst provider = await dbContext.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\tif (!provider || provider.disabledAt) {\n\t\t\treturn new ResponseError('PROVIDER_NOT_FOUND', `Identity provider ${idpSlug} not found`)\n\t\t}\n\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\ttry {\n\t\t\tconst initResponse = await providerService.initAuth(validatedConfig, data)\n\n\t\t\tconst publicConfig = providerService.getPublicConfiguration\n\t\t\t\t? providerService.getPublicConfiguration(validatedConfig)\n\t\t\t\t: validatedConfig\n\n\t\t\treturn new ResponseOk({\n\t\t\t\t...initResponse,\n\t\t\t\tidpConfiguration: provider.initReturnsConfig ? publicConfig : null,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message)\n\t\t\t}\n\t\t\tthrow e\n\t\t}\n\t}\n\n\tprivate async resolvePerson(db: DatabaseContext, claim: IDPResponse, provider: IdentityProviderRow): Promise<IDPSignInManager.ResolvePersonResult> {\n\t\tconst personByIdPQuery = new PersonByIdPQuery(provider.id, claim.externalIdentifier)\n\t\tconst personByIdp = await db.queryHandler.fetch(personByIdPQuery)\n\t\tif (personByIdp) {\n\t\t\treturn { person: personByIdp }\n\t\t}\n\n\t\tif (!provider.exclusive) {\n\t\t\tconst personByEmail = typeof claim.email === 'string' && claim.email\n\t\t\t\t? await db.queryHandler.fetch(PersonQuery.byEmail(claim.email))\n\t\t\t\t: null\n\t\t\tif (personByEmail) {\n\t\t\t\t// Auto-linking an IdP identity to a pre-existing local account by\n\t\t\t\t// e-mail is takeover-grade: if the provider returns an unverified\n\t\t\t\t// (or attacker-controlled) e-mail, it could hijack the account. When\n\t\t\t\t// the provider requires a verified e-mail, refuse to link — and to\n\t\t\t\t// sign in — unless the address is asserted verified. `assumeEmailVerified`\n\t\t\t\t// lets a trusted IdP that never emits the claim still satisfy the gate.\n\t\t\t\tconst emailVerified = provider.assumeEmailVerified || claim.emailVerified === true\n\t\t\t\tif (provider.requireVerifiedEmail && !emailVerified) {\n\t\t\t\t\treturn { person: null, blockedReason: 'idp_email_unverified', blockedPersonId: personByEmail.id }\n\t\t\t\t}\n\t\t\t\tawait this.saveIdpIdentifier(db, provider, claim, personByEmail)\n\t\t\t\treturn { person: personByEmail }\n\t\t\t}\n\t\t}\n\n\t\tif (provider.autoSignUp) {\n\t\t\tconst signedUpPerson = await this.signUp(db, claim, provider)\n\t\t\tawait this.saveIdpIdentifier(db, provider, claim, signedUpPerson)\n\t\t\treturn { person: signedUpPerson, isNewPerson: true }\n\t\t}\n\n\t\treturn { person: null }\n\t}\n\n\tprivate async signUp(db: DatabaseContext, { email, name, externalIdentifier }: IDPResponse, provider: IdentityProviderRow): Promise<PersonRow> {\n\t\tconst roles = [TenantRole.PERSON]\n\t\tconst identityId = await db.commandBus.execute(new CreateIdentityCommand(roles))\n\t\tconst newPerson = await db.commandBus.execute(\n\t\t\tnew CreatePersonCommand({\n\t\t\t\tidentityId,\n\t\t\t\temail: provider.exclusive ? undefined : email,\n\t\t\t\tpassword: NoPassword,\n\t\t\t\tname: name ?? email?.split('@')[0] ?? externalIdentifier,\n\t\t\t\tidpOnly: provider.exclusive,\n\t\t\t}),\n\t\t)\n\n\t\treturn {\n\t\t\t...newPerson,\n\t\t\troles,\n\t\t}\n\t}\n\n\tprivate async saveIdpIdentifier(db: DatabaseContext, provider: IdentityProviderRow, claim: IDPResponse, person: PersonRow) {\n\t\tawait db.commandBus.execute(new CreatePersonIdentityProviderIdentifierCommand(provider.id, person.id, claim.externalIdentifier))\n\t}\n}\n\nnamespace IDPSignInManager {\n\texport type ResolvePersonResult = {\n\t\treadonly person: PersonRow | null\n\t\treadonly blockedReason?: 'idp_email_unverified'\n\t\treadonly blockedPersonId?: string\n\t\t/** True when the person row was just created by auto-sign-up (drives the `sticky` claim-mapping policy). */\n\t\treadonly isNewPerson?: boolean\n\t}\n\n\texport type InitSignInIDPResponse = Response<InitSignInIdpResult, InitSignInIdpErrorCode>\n\n\tinterface SignInIDPResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\treadonly idpResponse?: Record<string, unknown>\n\t\t/** Identity provider the sign-in went through (for the resolver's audit). */\n\t\treadonly identityProviderId?: string\n\t\t/** Claim-mapping (A09) before/after delta — present only when a mapping changed grants. */\n\t\treadonly claimMappingAudit?: ClaimMappingAudit | null\n\t\t/** True when claim mapping failed and was skipped (fail-open) — drives the `idp_role_mapping_failed` audit. */\n\t\treadonly claimMappingFailed?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\n\texport type SignInIDPResponse = Response<SignInIDPResult, SignInIdpErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n}\n\nexport { IDPSignInManager }\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"IDPSignInManager.js","sources":["../../../../../../packages/engine-tenant-api/src/model/service/IDPSignInManager.ts"],"sourcesContent":["import { ApiKeyManager } from './apiKey/index.js'\nimport { IdentityProviderBySlugQuery, PersonByIdPQuery, PersonQuery, PersonRow } from '../queries/index.js'\nimport { IDPHandlerRegistry, IDPResponse, IDPResponseError, IDPValidationError } from './idp/index.js'\nimport { Response, ResponseError, ResponseOk } from '../utils/Response.js'\nimport { InitSignInIdpErrorCode, InitSignInIdpResult, SignInIdpErrorCode } from '../../schema/index.js'\nimport { DatabaseContext } from '../utils/index.js'\nimport { ApiKeyRequestInfo, CreateIdentityCommand, CreatePersonCommand, CreatePersonIdentityProviderIdentifierCommand } from '../commands/index.js'\nimport { CreateIdpSessionCommand } from '../commands/idp/CreateIdpSessionCommand.js'\nimport { CreateAuthLogEntryCommand } from '../commands/authLog/CreateAuthLogEntryCommand.js'\nimport { TenantRole } from '../authorization/index.js'\nimport { NoPassword } from '../dtos/index.js'\nimport { IdentityProviderRow } from '../queries/idp/types.js'\nimport { AuthLogService } from './AuthLogService.js'\nimport { ClaimMappingAudit, IDPClaimSyncService } from './idp/IDPClaimSyncService.js'\nimport { ClaimMapping, parseClaimMapping } from './idp/ClaimMapping.js'\nimport { Logger } from '@contember/logger'\n\nclass IDPSignInManager {\n\tconstructor(\n\t\tprivate readonly apiKeyManager: ApiKeyManager,\n\t\tprivate readonly idpRegistry: IDPHandlerRegistry,\n\t\tprivate readonly claimSyncService: IDPClaimSyncService,\n\t\tprivate readonly logger: Logger,\n\t) {}\n\n\tasync signInIDP(\n\t\tdbContext: DatabaseContext,\n\t\tidpSlug: string,\n\t\tresponseData: unknown,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<IDPSignInManager.SignInIDPResponse> {\n\t\treturn dbContext.transaction(async (db): Promise<IDPSignInManager.SignInIDPResponse> => {\n\t\t\tconst provider = await db.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\t\tif (!provider || provider.disabledAt) {\n\t\t\t\tthrow new Error('provider not found')\n\t\t\t}\n\t\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\t\tlet claim: IDPResponse\n\t\t\ttry {\n\t\t\t\tclaim = await providerService.processResponse(\n\t\t\t\t\tvalidatedConfig,\n\t\t\t\t\tresponseData,\n\t\t\t\t)\n\t\t\t} catch (e) {\n\t\t\t\tif (e instanceof IDPResponseError) {\n\t\t\t\t\treturn new ResponseError('INVALID_IDP_RESPONSE', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message, {\n\t\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\t\tpersonId: undefined,\n\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t}),\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\tthrow e\n\t\t\t}\n\t\t\tconst resolved = await this.resolvePerson(db, claim, provider)\n\t\t\tconst personRow = resolved.person\n\n\t\t\tif (!personRow) {\n\t\t\t\t// Keep the external error generic (no account-existence leak), but\n\t\t\t\t// record the email-verification gate block distinctly in the audit\n\t\t\t\t// log — it is a takeover-grade event, not an ordinary \"not found\".\n\t\t\t\treturn new ResponseError('PERSON_NOT_FOUND', `Person ${claim.email} not found`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: resolved.blockedPersonId,\n\t\t\t\t\t\teventData: resolved.blockedReason ? { reason: resolved.blockedReason } : undefined,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\tif (personRow.disabled_at !== null) {\n\t\t\t\treturn new ResponseError('PERSON_DISABLED', `Person with e-mail ${claim.email} is disabled`, {\n\t\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t}),\n\t\t\t\t})\n\t\t\t}\n\n\t\t\t// A09 — map IdP claims to project memberships. Returns a `{ before, after }` delta only\n\t\t\t// when something actually changed; the resolver emits the `idp_role_mapped` audit from it\n\t\t\t// (audit stays at the resolver layer).\n\t\t\t//\n\t\t\t// Fail-open on a MALFORMED/out-of-band `claimMapping`: parsing is caught so a bad config can\n\t\t\t// never lock the user out — sign-in proceeds without claim mapping and the resolver emits an\n\t\t\t// `idp_role_mapping_failed` marker. A DB error during the apply itself is NOT caught: it\n\t\t\t// propagates and rolls back the whole sign-in transaction, so a half-applied set of grants is\n\t\t\t// never committed (failing sign-in on an infra error is the safe outcome).\n\t\t\tlet mapping: ClaimMapping | null = null\n\t\t\tlet claimMappingFailed = false\n\t\t\ttry {\n\t\t\t\tmapping = parseClaimMapping(provider.configuration)\n\t\t\t} catch (e) {\n\t\t\t\tthis.logger.warn(e, { message: 'IdP sign-in: claim mapping configuration is invalid', identityProviderId: provider.id })\n\t\t\t\tclaimMappingFailed = true\n\t\t\t}\n\t\t\tlet claimMappingAudit: ClaimMappingAudit | null = null\n\t\t\tif (mapping) {\n\t\t\t\tconst syncResult = await this.claimSyncService.sync(\n\t\t\t\t\tdb,\n\t\t\t\t\tmapping,\n\t\t\t\t\tclaim,\n\t\t\t\t\t{ id: personRow.identity_id },\n\t\t\t\t\tresolved.isNewPerson === true,\n\t\t\t\t)\n\t\t\t\tclaimMappingAudit = syncResult.audit\n\t\t\t\t// The apply-time safety backstop dropped a configured rule (config-time validation was skipped, or\n\t\t\t\t// the project's ACL drifted since): surface the same fail-open marker as a malformed config, so a\n\t\t\t\t// silently-unapplied grant is visible to the operator. The resolver emits `idp_role_mapping_failed`.\n\t\t\t\tif (syncResult.droppedUnsafeRules) {\n\t\t\t\t\tclaimMappingFailed = true\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst { id: apiKeyId, token: sessionToken } = await this.apiKeyManager.createSessionApiKeyWithId(\n\t\t\t\tdb,\n\t\t\t\tpersonRow.identity_id,\n\t\t\t\texpiration,\n\t\t\t\trequestInfo,\n\t\t\t\ttrustForwardedInfo,\n\t\t\t)\n\n\t\t\t// Bind the federated-session state to this session so it can be re-validated\n\t\t\t// against the IdP later. Present only when the provider supports it and\n\t\t\t// revalidation is enabled on the IdP configuration (A24). Refresh tokens must be\n\t\t\t// encrypted at rest, so when no encryption key is configured we skip persisting a\n\t\t\t// token-bearing session (it would otherwise crash or store secrets in plaintext) —\n\t\t\t// the session then behaves like a plain session with no re-validation.\n\t\t\tif (claim.idpSession) {\n\t\t\t\tconst hasTokens = !!claim.idpSession.tokens && Object.keys(claim.idpSession.tokens).length > 0\n\t\t\t\tif (!hasTokens || db.providers.encryptionEnabled) {\n\t\t\t\t\tawait db.commandBus.execute(new CreateIdpSessionCommand(apiKeyId, provider.id, claim.idpSession))\n\t\t\t\t} else {\n\t\t\t\t\t// Re-validation is enabled on this IdP but no encryption key is configured, so we\n\t\t\t\t\t// won't store the token-bearing session — it silently degrades to a plain,\n\t\t\t\t\t// non-revalidated session. Audit the downgrade (errorCode `encryption_disabled`)\n\t\t\t\t\t// so the operator can see that a session they expected to be continuously\n\t\t\t\t\t// re-validated is in fact not protected, instead of failing closed at sign-in.\n\t\t\t\t\t// Best-effort, in a savepoint: a failed insert would otherwise abort the whole\n\t\t\t\t\t// sign-in transaction, and a lost audit row must not block the sign-in.\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait db.commandBus.transaction(bus =>\n\t\t\t\t\t\t\tbus.execute(\n\t\t\t\t\t\t\t\tnew CreateAuthLogEntryCommand({\n\t\t\t\t\t\t\t\t\ttype: 'idp_session_revalidation_failed',\n\t\t\t\t\t\t\t\t\tinvokedById: personRow.identity_id,\n\t\t\t\t\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\t\t\t\terrorCode: 'encryption_disabled',\n\t\t\t\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\t\t\t\teventData: { apiKeyId },\n\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t)\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tthis.logger.error(e, { message: 'IdP sign-in: writing the encryption_disabled audit entry failed', identityProviderId: provider.id })\n\t\t\t\t\t\t// the savepoint is rolled back, the sign-in transaction stays usable\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn new ResponseOk({\n\t\t\t\tperson: personRow,\n\t\t\t\ttoken: sessionToken,\n\t\t\t\tidpResponse: claim,\n\t\t\t\tclaimMappingAudit,\n\t\t\t\tclaimMappingFailed,\n\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t[AuthLogService.Key]: new AuthLogService.Bag({\n\t\t\t\t\tpersonId: personRow.id,\n\t\t\t\t\tidentityProviderId: provider.id,\n\t\t\t\t\tpersonInput: claim.email,\n\t\t\t\t}),\n\t\t\t})\n\t\t})\n\t}\n\n\tasync initSignInIDP(dbContext: DatabaseContext, idpSlug: string, data: unknown): Promise<IDPSignInManager.InitSignInIDPResponse> {\n\t\tconst provider = await dbContext.queryHandler.fetch(new IdentityProviderBySlugQuery(idpSlug))\n\t\tif (!provider || provider.disabledAt) {\n\t\t\treturn new ResponseError('PROVIDER_NOT_FOUND', `Identity provider ${idpSlug} not found`)\n\t\t}\n\t\tconst providerService = this.idpRegistry.getHandler(provider.type)\n\t\tconst validatedConfig = providerService.validateConfiguration(provider.configuration)\n\t\ttry {\n\t\t\tconst initResponse = await providerService.initAuth(validatedConfig, data)\n\n\t\t\tconst publicConfig = providerService.getPublicConfiguration\n\t\t\t\t? providerService.getPublicConfiguration(validatedConfig)\n\t\t\t\t: validatedConfig\n\n\t\t\treturn new ResponseOk({\n\t\t\t\t...initResponse,\n\t\t\t\tidpConfiguration: provider.initReturnsConfig ? publicConfig : null,\n\t\t\t})\n\t\t} catch (e) {\n\t\t\tif (e instanceof IDPValidationError) {\n\t\t\t\treturn new ResponseError('IDP_VALIDATION_FAILED', e.message)\n\t\t\t}\n\t\t\tthrow e\n\t\t}\n\t}\n\n\tprivate async resolvePerson(db: DatabaseContext, claim: IDPResponse, provider: IdentityProviderRow): Promise<IDPSignInManager.ResolvePersonResult> {\n\t\tconst personByIdPQuery = new PersonByIdPQuery(provider.id, claim.externalIdentifier)\n\t\tconst personByIdp = await db.queryHandler.fetch(personByIdPQuery)\n\t\tif (personByIdp) {\n\t\t\treturn { person: personByIdp }\n\t\t}\n\n\t\tif (!provider.exclusive) {\n\t\t\tconst personByEmail = typeof claim.email === 'string' && claim.email\n\t\t\t\t? await db.queryHandler.fetch(PersonQuery.byEmail(claim.email))\n\t\t\t\t: null\n\t\t\tif (personByEmail) {\n\t\t\t\t// Auto-linking an IdP identity to a pre-existing local account by\n\t\t\t\t// e-mail is takeover-grade: if the provider returns an unverified\n\t\t\t\t// (or attacker-controlled) e-mail, it could hijack the account. When\n\t\t\t\t// the provider requires a verified e-mail, refuse to link — and to\n\t\t\t\t// sign in — unless the address is asserted verified. `assumeEmailVerified`\n\t\t\t\t// lets a trusted IdP that never emits the claim still satisfy the gate.\n\t\t\t\tconst emailVerified = provider.assumeEmailVerified || claim.emailVerified === true\n\t\t\t\tif (provider.requireVerifiedEmail && !emailVerified) {\n\t\t\t\t\treturn { person: null, blockedReason: 'idp_email_unverified', blockedPersonId: personByEmail.id }\n\t\t\t\t}\n\t\t\t\tawait this.saveIdpIdentifier(db, provider, claim, personByEmail)\n\t\t\t\treturn { person: personByEmail }\n\t\t\t}\n\t\t}\n\n\t\tif (provider.autoSignUp) {\n\t\t\tconst signedUpPerson = await this.signUp(db, claim, provider)\n\t\t\tawait this.saveIdpIdentifier(db, provider, claim, signedUpPerson)\n\t\t\treturn { person: signedUpPerson, isNewPerson: true }\n\t\t}\n\n\t\treturn { person: null }\n\t}\n\n\tprivate async signUp(db: DatabaseContext, { email, name, externalIdentifier }: IDPResponse, provider: IdentityProviderRow): Promise<PersonRow> {\n\t\tconst roles = [TenantRole.PERSON]\n\t\tconst identityId = await db.commandBus.execute(new CreateIdentityCommand(roles))\n\t\tconst newPerson = await db.commandBus.execute(\n\t\t\tnew CreatePersonCommand({\n\t\t\t\tidentityId,\n\t\t\t\temail: provider.exclusive ? undefined : email,\n\t\t\t\tpassword: NoPassword,\n\t\t\t\tname: name ?? email?.split('@')[0] ?? externalIdentifier,\n\t\t\t\tidpOnly: provider.exclusive,\n\t\t\t}),\n\t\t)\n\n\t\treturn {\n\t\t\t...newPerson,\n\t\t\troles,\n\t\t}\n\t}\n\n\tprivate async saveIdpIdentifier(db: DatabaseContext, provider: IdentityProviderRow, claim: IDPResponse, person: PersonRow) {\n\t\tawait db.commandBus.execute(new CreatePersonIdentityProviderIdentifierCommand(provider.id, person.id, claim.externalIdentifier))\n\t}\n}\n\nnamespace IDPSignInManager {\n\texport type ResolvePersonResult = {\n\t\treadonly person: PersonRow | null\n\t\treadonly blockedReason?: 'idp_email_unverified'\n\t\treadonly blockedPersonId?: string\n\t\t/** True when the person row was just created by auto-sign-up (drives the `sticky` claim-mapping policy). */\n\t\treadonly isNewPerson?: boolean\n\t}\n\n\texport type InitSignInIDPResponse = Response<InitSignInIdpResult, InitSignInIdpErrorCode>\n\n\tinterface SignInIDPResult {\n\t\treadonly person: PersonRow\n\t\treadonly token: string\n\t\treadonly idpResponse?: Record<string, unknown>\n\t\t/** Identity provider the sign-in went through (for the resolver's audit). */\n\t\treadonly identityProviderId?: string\n\t\t/** Claim-mapping (A09) before/after delta — present only when a mapping changed grants. */\n\t\treadonly claimMappingAudit?: ClaimMappingAudit | null\n\t\t/** True when claim mapping failed and was skipped (fail-open) — drives the `idp_role_mapping_failed` audit. */\n\t\treadonly claimMappingFailed?: boolean\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}\n\n\texport type SignInIDPResponse = Response<SignInIDPResult, SignInIdpErrorCode, {\n\t\t[AuthLogService.Key]: AuthLogService.Bag\n\t}>\n}\n\nexport { IDPSignInManager }\n"],"names":[],"mappings":"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"}
|
|
@@ -36,10 +36,11 @@ class ApiKeyManager {
|
|
|
36
36
|
if (unpersistedResult !== null) {
|
|
37
37
|
return new Response.ResponseOk(unpersistedResult);
|
|
38
38
|
}
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
39
|
+
const fetchedApiKey = await this.fetchApiKeyByToken(dbContext, readDbContext, token);
|
|
40
|
+
if (fetchedApiKey === null) {
|
|
41
41
|
return new Response.ResponseError("not_found", "API key was not found");
|
|
42
42
|
}
|
|
43
|
+
const apiKeyRow = fetchedApiKey.row;
|
|
43
44
|
if (apiKeyRow.disabled_at !== null) {
|
|
44
45
|
return new Response.ResponseError(
|
|
45
46
|
"disabled",
|
|
@@ -87,7 +88,7 @@ class ApiKeyManager {
|
|
|
87
88
|
userAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent
|
|
88
89
|
} : requestInfo;
|
|
89
90
|
if (this.idpSessionRevalidator) {
|
|
90
|
-
const outcome = await this.idpSessionRevalidator.revalidate(dbContext,
|
|
91
|
+
const outcome = await this.idpSessionRevalidator.revalidate(dbContext, fetchedApiKey.sourceDbContext, apiKeyRow, effectiveInfo);
|
|
91
92
|
if (outcome === "revoked") {
|
|
92
93
|
return new Response.ResponseError("disabled", "IdP session was revoked");
|
|
93
94
|
}
|
|
@@ -120,6 +121,18 @@ class ApiKeyManager {
|
|
|
120
121
|
)
|
|
121
122
|
);
|
|
122
123
|
}
|
|
124
|
+
async fetchApiKeyByToken(dbContext, readDbContext, token) {
|
|
125
|
+
const query = new ApiKeyQuery.ApiKeyByTokenQuery(token);
|
|
126
|
+
const replicaRow = await readDbContext.queryHandler.fetch(query);
|
|
127
|
+
if (replicaRow !== null) {
|
|
128
|
+
return { row: replicaRow, sourceDbContext: readDbContext };
|
|
129
|
+
}
|
|
130
|
+
if (readDbContext.client.connection === dbContext.client.connection) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const primaryRow = await dbContext.queryHandler.fetch(query);
|
|
134
|
+
return primaryRow !== null ? { row: primaryRow, sourceDbContext: dbContext } : null;
|
|
135
|
+
}
|
|
123
136
|
async createSessionApiKey(dbContext, identityId, expiration, requestInfo, trustForwardedInfo) {
|
|
124
137
|
const { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo);
|
|
125
138
|
return token;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ApiKeyManager.cjs","sources":["../../../../../../../packages/engine-tenant-api/src/model/service/apiKey/ApiKeyManager.ts"],"sourcesContent":["import {\n\tApiKeyRequestInfo,\n\tCreateApiKeyCommand,\n\tDisableApiKeyCommand,\n\tDisableIdentityApiKeysCommand,\n\tDisableOneOffApiKeyCommand,\n\tPROLONG_THROTTLE_MS,\n\tProlongApiKeyCommand,\n} from '../../commands/index.js'\nimport { ApiKey } from '../../type/index.js'\n\nimport { Response, ResponseError, ResponseOk } from '../../utils/Response.js'\nimport { DatabaseContext, TokenHash } from '../../utils/index.js'\nimport { ApiKeyService, CreateApiKeyResponse } from './ApiKeyService.js'\nimport assert from 'node:assert'\nimport { Acl } from '@contember/schema'\nimport { ApiKeyByIdQuery, ApiKeyByTokenQuery, ApiKeyRow, ConfigurationQuery, IdentityQuery } from '../../queries/index.js'\nimport PostgresInterval from 'postgres-interval'\nimport { Config } from '../../type/Config.js'\nimport { intervalToPostgres, intervalToSeconds } from '../../utils/interval.js'\nimport { AuthPolicyResolver } from '../AuthPolicyResolver.js'\nimport { AuthLogService } from '../AuthLogService.js'\nimport { IdpSessionRevalidator } from '../idp/IdpSessionRevalidator.js'\nimport { UnpersistedApiKeyManager } from './UnpersistedApiKeyManager.js'\n\nexport class ApiKeyManager {\n\tconstructor(\n\t\tprivate readonly apiKeyService: ApiKeyService,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly authLogService: AuthLogService,\n\t\tprivate readonly unpersistedApiKeyManager: UnpersistedApiKeyManager = new UnpersistedApiKeyManager(),\n\t\tprivate readonly idpSessionRevalidator?: IdpSessionRevalidator,\n\t) {}\n\n\tasync verifyAndProlong(\n\t\tdbContext: DatabaseContext,\n\t\treadDbContext: DatabaseContext,\n\t\ttoken: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\tforwardedInfo?: ApiKeyRequestInfo,\n\t): Promise<VerifyResponse> {\n\t\tconst unpersistedResult = this.unpersistedApiKeyManager.verify(token)\n\t\tif (unpersistedResult !== null) {\n\t\t\t// Configured (unpersisted) root token: no DB row to prolong/track.\n\t\t\treturn new ResponseOk(unpersistedResult)\n\t\t}\n\n\t\tconst apiKeyRow = await readDbContext.queryHandler.fetch(new ApiKeyByTokenQuery(token))\n\t\tif (apiKeyRow === null) {\n\t\t\treturn new ResponseError(VerifyErrorCode.NOT_FOUND, 'API key was not found')\n\t\t}\n\n\t\tif (apiKeyRow.disabled_at !== null) {\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key was disabled at ${apiKeyRow.disabled_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// Single injectable clock for all session-policy time math (matches issuance,\n\t\t// which uses providers.now()), so the A19 idle/max logic is deterministically testable.\n\t\tconst now = dbContext.providers.now()\n\t\tif (apiKeyRow.expires_at !== null && apiKeyRow.expires_at <= now) {\n\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, `API key expired at ${apiKeyRow.expires_at.toISOString()}`)\n\t\t}\n\n\t\t// A19: absolute hard cap (defensive — the prolong clamp should already keep\n\t\t// expires_at <= max_expires_at, but enforce it here regardless).\n\t\tif (apiKeyRow.max_expires_at !== null && apiKeyRow.max_expires_at <= now) {\n\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key reached its maximum lifetime at ${apiKeyRow.max_expires_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// A19: idle timeout. Reject + disable when the session has been idle longer\n\t\t// than the policy's idle_timeout. A null idle_timeout (today's default) or a\n\t\t// never-used key (last_used_at null) are never idle-expired.\n\t\tif (apiKeyRow.idle_timeout !== null && apiKeyRow.last_used_at !== null) {\n\t\t\tconst idleMs = intervalToSeconds(apiKeyRow.idle_timeout) * 1000\n\t\t\t// last_used_at is written at most once per PROLONG_THROTTLE_MS (tracking is\n\t\t\t// throttled), so it can legitimately lag a continuously-active session by up\n\t\t\t// to that window. Add the throttle as slack so a small idle_timeout can't\n\t\t\t// idle-expire a session that is actually in use.\n\t\t\tif (now.getTime() - apiKeyRow.last_used_at.getTime() > idleMs + PROLONG_THROTTLE_MS) {\n\t\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\t\ttry {\n\t\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\t\ttype: 'session_expired_idle',\n\t\t\t\t\t\tidentityId: apiKeyRow.identity_id,\n\t\t\t\t\t\tpersonId: apiKeyRow.person_id,\n\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\teventData: {\n\t\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at?.toISOString() ?? null,\n\t\t\t\t\t\t\tidleTimeout: String(apiKeyRow.idle_timeout),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\t/* best-effort: auth path must stay resilient */\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t\t`API key was idle since ${apiKeyRow.last_used_at.toISOString()} and exceeded the idle timeout`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\tconst effectiveInfo: ApiKeyRequestInfo | undefined = apiKeyRow.trust_forwarded_info && forwardedInfo\n\t\t\t? {\n\t\t\t\tip: forwardedInfo.ip ?? requestInfo?.ip,\n\t\t\t\tuserAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent,\n\t\t\t}\n\t\t\t: requestInfo\n\n\t\t// Re-validate IdP-backed sessions against their identity provider (A24). In the\n\t\t// default `background` mode this returns immediately and revalidates after the\n\t\t// response (revocation lands on the next request); in `blocking` mode it can revoke\n\t\t// the current request. No-op for password sessions and IdPs without revalidation.\n\t\t// `effectiveInfo` is passed so the audit entries record the originating request's IP/UA.\n\t\tif (this.idpSessionRevalidator) {\n\t\t\tconst outcome = await this.idpSessionRevalidator.revalidate(dbContext, readDbContext, apiKeyRow, effectiveInfo)\n\t\t\tif (outcome === 'revoked') {\n\t\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, 'IdP session was revoked')\n\t\t\t}\n\t\t}\n\n\t\t// Detached (runs after the response), so a rejection here would be an unhandledRejection and can take the\n\t\t// worker down. Tracking is best-effort — the next request writes it again.\n\t\tsetImmediate(() => {\n\t\t\tdbContext.commandBus.execute(\n\t\t\t\tnew ProlongApiKeyCommand(\n\t\t\t\t\tapiKeyRow.id,\n\t\t\t\t\tapiKeyRow.type,\n\t\t\t\t\tapiKeyRow.expiration,\n\t\t\t\t\tapiKeyRow.expires_at,\n\t\t\t\t\teffectiveInfo,\n\t\t\t\t\t{\n\t\t\t\t\t\tlastIp: apiKeyRow.last_ip,\n\t\t\t\t\t\tlastUserAgent: apiKeyRow.last_user_agent,\n\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at,\n\t\t\t\t\t},\n\t\t\t\t\tapiKeyRow.max_expires_at,\n\t\t\t\t),\n\t\t\t).catch(() => {})\n\t\t})\n\n\t\treturn new ResponseOk(\n\t\t\tnew VerifyResult(\n\t\t\t\tapiKeyRow.identity_id,\n\t\t\t\tapiKeyRow.id,\n\t\t\t\tapiKeyRow.roles,\n\t\t\t\tapiKeyRow.person_id,\n\t\t\t\tapiKeyRow.trust_forwarded_info,\n\t\t\t),\n\t\t)\n\t}\n\n\tasync createSessionApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<string> {\n\t\tconst { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo)\n\t\treturn token\n\t}\n\n\t/**\n\t * Like {@link createSessionApiKey} but also returns the api_key id — needed when the\n\t * caller has to bind another row to the session (e.g. an `idp_session` for IdP\n\t * re-validation).\n\t */\n\tasync createSessionApiKeyWithId(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<{ id: string; token: string }> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\n\t\t// A19: snapshot the effective session policy onto the api_key at sign-in.\n\t\t// With no matching policy, every field is null/baseline and the result is\n\t\t// byte-for-byte today's behavior.\n\t\tconst [identityRow] = await dbContext.queryHandler.fetch(new IdentityQuery([identityId]))\n\t\tconst globalRoles = identityRow?.roles ?? []\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, identityId, globalRoles)\n\n\t\tconst defaultExpirationMinutes = intervalToSeconds(config.login.defaultTokenExpiration) / 60\n\t\t// remember_me_allowed === false: ignore a client-supplied longer lifetime and\n\t\t// force the default. (true/null leave the requested expiration untouched.)\n\t\tconst requestedMinutes = policy.rememberMeAllowed === false\n\t\t\t? defaultExpirationMinutes\n\t\t\t: (expiration ?? defaultExpirationMinutes)\n\n\t\t// Cap by maxTokenExpiration (existing behavior) AND by the policy's\n\t\t// tokenExpiration (A19) — strictest wins.\n\t\tlet expirationCapped = config.login.maxTokenExpiration\n\t\t\t? Math.min(requestedMinutes, intervalToSeconds(config.login.maxTokenExpiration) / 60)\n\t\t\t: requestedMinutes\n\t\tif (policy.tokenExpiration) {\n\t\t\texpirationCapped = Math.min(expirationCapped, intervalToSeconds(policy.tokenExpiration) / 60)\n\t\t}\n\n\t\t// Absolute hard cap: issued_at + policy.tokenExpiration. NULL = uncapped\n\t\t// sliding window (today's behavior).\n\t\tconst issuedAt = dbContext.providers.now()\n\t\tconst maxExpiresAt = policy.tokenExpiration\n\t\t\t? new Date(issuedAt.getTime() + intervalToSeconds(policy.tokenExpiration) * 1000)\n\t\t\t: null\n\n\t\tconst command = new CreateApiKeyCommand({\n\t\t\ttype: ApiKey.Type.SESSION,\n\t\t\tidentityId,\n\t\t\texpiration: expirationCapped,\n\t\t\trequestInfo,\n\t\t\ttrustForwardedInfo,\n\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\tmaxExpiresAt,\n\t\t})\n\t\tconst { id, token } = await dbContext.commandBus.execute(command)\n\t\tassert(token !== undefined)\n\n\t\t// A19: audit only when a policy is actually in effect. With zero auth_policy\n\t\t// rows the resolved policy is inert and no extra insert happens.\n\t\tconst policyApplied = policy.mfaRequired === true\n\t\t\t|| policy.tokenExpiration !== null\n\t\t\t|| policy.idleTimeout !== null\n\t\t\t|| policy.rememberMeAllowed !== null\n\t\tif (policyApplied) {\n\t\t\ttry {\n\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\ttype: 'session_policy_applied',\n\t\t\t\t\tidentityId,\n\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\teventData: {\n\t\t\t\t\t\ttokenExpiration: policy.tokenExpiration ? intervalToPostgres(policy.tokenExpiration) : null,\n\t\t\t\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\t\t\t\trememberMeAllowed: policy.rememberMeAllowed,\n\t\t\t\t\t\tmfaRequired: policy.mfaRequired,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\t/* best-effort: sign-in must not break on an audit failure */\n\t\t\t}\n\t\t}\n\n\t\treturn { id, token }\n\t}\n\n\tasync findApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<ApiKeyRow | null> {\n\t\treturn await dbContext.queryHandler.fetch(\n\t\t\tnew ApiKeyByIdQuery(apiKeyId),\n\t\t)\n\t}\n\n\tasync disableOneOffApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableOneOffApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<boolean> {\n\t\treturn await dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableIdentityApiKeys(dbContext: DatabaseContext, identityId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableIdentityApiKeysCommand(identityId))\n\t}\n\n\tasync createGlobalPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tdescription: string,\n\t\troles: readonly string[],\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createPermanentApiKey(db, description, roles, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n\n\tasync createProjectPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tprojectId: string,\n\t\tmemberships: readonly Acl.Membership[],\n\t\tdescription: string,\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createProjectPermanentApiKey(db, projectId, memberships, description, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n}\n\nexport type VerifyResponse = Response<VerifyResult, VerifyErrorCode>\n\nexport class VerifyResult {\n\treadonly valid = true\n\n\tconstructor(\n\t\tpublic readonly identityId: string,\n\t\tpublic readonly apiKeyId: string,\n\t\tpublic readonly roles: string[],\n\t\tpublic readonly personId: string | null,\n\t\tpublic readonly trustForwardedInfo: boolean = false,\n\t) {}\n}\n\nexport enum VerifyErrorCode {\n\tNOT_FOUND = 'not_found',\n\tDISABLED = 'disabled',\n\tEXPIRED = 'expired',\n\tNO_AUTH_HEADER = 'no_auth_header',\n\tINVALID_AUTH_HEADER = 'invalid_auth_header',\n}\n"],"names":["UnpersistedApiKeyManager","ResponseOk","ApiKeyByTokenQuery","ResponseError","DisableApiKeyCommand","intervalToSeconds","PROLONG_THROTTLE_MS","ProlongApiKeyCommand","ConfigurationQuery","IdentityQuery","CreateApiKeyCommand","ApiKey","intervalToPostgres","ApiKeyByIdQuery","DisableOneOffApiKeyCommand","DisableIdentityApiKeysCommand","VerifyErrorCode"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ApiKeyManager.cjs","sources":["../../../../../../../packages/engine-tenant-api/src/model/service/apiKey/ApiKeyManager.ts"],"sourcesContent":["import {\n\tApiKeyRequestInfo,\n\tCreateApiKeyCommand,\n\tDisableApiKeyCommand,\n\tDisableIdentityApiKeysCommand,\n\tDisableOneOffApiKeyCommand,\n\tPROLONG_THROTTLE_MS,\n\tProlongApiKeyCommand,\n} from '../../commands/index.js'\nimport { ApiKey } from '../../type/index.js'\n\nimport { Response, ResponseError, ResponseOk } from '../../utils/Response.js'\nimport { DatabaseContext, TokenHash } from '../../utils/index.js'\nimport { ApiKeyService, CreateApiKeyResponse } from './ApiKeyService.js'\nimport assert from 'node:assert'\nimport { Acl } from '@contember/schema'\nimport { ApiKeyByIdQuery, ApiKeyByTokenQuery, ApiKeyRow, ConfigurationQuery, IdentityQuery } from '../../queries/index.js'\nimport PostgresInterval from 'postgres-interval'\nimport { Config } from '../../type/Config.js'\nimport { intervalToPostgres, intervalToSeconds } from '../../utils/interval.js'\nimport { AuthPolicyResolver } from '../AuthPolicyResolver.js'\nimport { AuthLogService } from '../AuthLogService.js'\nimport { IdpSessionRevalidator } from '../idp/IdpSessionRevalidator.js'\nimport { UnpersistedApiKeyManager } from './UnpersistedApiKeyManager.js'\n\nexport class ApiKeyManager {\n\tconstructor(\n\t\tprivate readonly apiKeyService: ApiKeyService,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly authLogService: AuthLogService,\n\t\tprivate readonly unpersistedApiKeyManager: UnpersistedApiKeyManager = new UnpersistedApiKeyManager(),\n\t\tprivate readonly idpSessionRevalidator?: IdpSessionRevalidator,\n\t) {}\n\n\tasync verifyAndProlong(\n\t\tdbContext: DatabaseContext,\n\t\treadDbContext: DatabaseContext,\n\t\ttoken: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\tforwardedInfo?: ApiKeyRequestInfo,\n\t): Promise<VerifyResponse> {\n\t\tconst unpersistedResult = this.unpersistedApiKeyManager.verify(token)\n\t\tif (unpersistedResult !== null) {\n\t\t\t// Configured (unpersisted) root token: no DB row to prolong/track.\n\t\t\treturn new ResponseOk(unpersistedResult)\n\t\t}\n\n\t\tconst fetchedApiKey = await this.fetchApiKeyByToken(dbContext, readDbContext, token)\n\t\tif (fetchedApiKey === null) {\n\t\t\treturn new ResponseError(VerifyErrorCode.NOT_FOUND, 'API key was not found')\n\t\t}\n\t\tconst apiKeyRow = fetchedApiKey.row\n\n\t\tif (apiKeyRow.disabled_at !== null) {\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key was disabled at ${apiKeyRow.disabled_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// Single injectable clock for all session-policy time math (matches issuance,\n\t\t// which uses providers.now()), so the A19 idle/max logic is deterministically testable.\n\t\tconst now = dbContext.providers.now()\n\t\tif (apiKeyRow.expires_at !== null && apiKeyRow.expires_at <= now) {\n\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, `API key expired at ${apiKeyRow.expires_at.toISOString()}`)\n\t\t}\n\n\t\t// A19: absolute hard cap (defensive — the prolong clamp should already keep\n\t\t// expires_at <= max_expires_at, but enforce it here regardless).\n\t\tif (apiKeyRow.max_expires_at !== null && apiKeyRow.max_expires_at <= now) {\n\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key reached its maximum lifetime at ${apiKeyRow.max_expires_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// A19: idle timeout. Reject + disable when the session has been idle longer\n\t\t// than the policy's idle_timeout. A null idle_timeout (today's default) or a\n\t\t// never-used key (last_used_at null) are never idle-expired.\n\t\tif (apiKeyRow.idle_timeout !== null && apiKeyRow.last_used_at !== null) {\n\t\t\tconst idleMs = intervalToSeconds(apiKeyRow.idle_timeout) * 1000\n\t\t\t// last_used_at is written at most once per PROLONG_THROTTLE_MS (tracking is\n\t\t\t// throttled), so it can legitimately lag a continuously-active session by up\n\t\t\t// to that window. Add the throttle as slack so a small idle_timeout can't\n\t\t\t// idle-expire a session that is actually in use.\n\t\t\tif (now.getTime() - apiKeyRow.last_used_at.getTime() > idleMs + PROLONG_THROTTLE_MS) {\n\t\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\t\ttry {\n\t\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\t\ttype: 'session_expired_idle',\n\t\t\t\t\t\tidentityId: apiKeyRow.identity_id,\n\t\t\t\t\t\tpersonId: apiKeyRow.person_id,\n\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\teventData: {\n\t\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at?.toISOString() ?? null,\n\t\t\t\t\t\t\tidleTimeout: String(apiKeyRow.idle_timeout),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\t/* best-effort: auth path must stay resilient */\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t\t`API key was idle since ${apiKeyRow.last_used_at.toISOString()} and exceeded the idle timeout`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\tconst effectiveInfo: ApiKeyRequestInfo | undefined = apiKeyRow.trust_forwarded_info && forwardedInfo\n\t\t\t? {\n\t\t\t\tip: forwardedInfo.ip ?? requestInfo?.ip,\n\t\t\t\tuserAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent,\n\t\t\t}\n\t\t\t: requestInfo\n\n\t\t// Re-validate IdP-backed sessions against their identity provider (A24). In the\n\t\t// default `background` mode this returns immediately and revalidates after the\n\t\t// response (revocation lands on the next request); in `blocking` mode it can revoke\n\t\t// the current request. No-op for password sessions and IdPs without revalidation.\n\t\t// `effectiveInfo` is passed so the audit entries record the originating request's IP/UA.\n\t\t// The idp_session row is written together with the api_key, so it is read from where the key was found.\n\t\tif (this.idpSessionRevalidator) {\n\t\t\tconst outcome = await this.idpSessionRevalidator.revalidate(dbContext, fetchedApiKey.sourceDbContext, apiKeyRow, effectiveInfo)\n\t\t\tif (outcome === 'revoked') {\n\t\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, 'IdP session was revoked')\n\t\t\t}\n\t\t}\n\n\t\t// Detached (runs after the response), so a rejection here would be an unhandledRejection and can take the\n\t\t// worker down. Tracking is best-effort — the next request writes it again.\n\t\tsetImmediate(() => {\n\t\t\tdbContext.commandBus.execute(\n\t\t\t\tnew ProlongApiKeyCommand(\n\t\t\t\t\tapiKeyRow.id,\n\t\t\t\t\tapiKeyRow.type,\n\t\t\t\t\tapiKeyRow.expiration,\n\t\t\t\t\tapiKeyRow.expires_at,\n\t\t\t\t\teffectiveInfo,\n\t\t\t\t\t{\n\t\t\t\t\t\tlastIp: apiKeyRow.last_ip,\n\t\t\t\t\t\tlastUserAgent: apiKeyRow.last_user_agent,\n\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at,\n\t\t\t\t\t},\n\t\t\t\t\tapiKeyRow.max_expires_at,\n\t\t\t\t),\n\t\t\t).catch(() => {})\n\t\t})\n\n\t\treturn new ResponseOk(\n\t\t\tnew VerifyResult(\n\t\t\t\tapiKeyRow.identity_id,\n\t\t\t\tapiKeyRow.id,\n\t\t\t\tapiKeyRow.roles,\n\t\t\t\tapiKeyRow.person_id,\n\t\t\t\tapiKeyRow.trust_forwarded_info,\n\t\t\t),\n\t\t)\n\t}\n\n\tprivate async fetchApiKeyByToken(dbContext: DatabaseContext, readDbContext: DatabaseContext, token: string): Promise<FetchedApiKey | null> {\n\t\tconst query = new ApiKeyByTokenQuery(token)\n\t\tconst replicaRow = await readDbContext.queryHandler.fetch(query)\n\t\tif (replicaRow !== null) {\n\t\t\treturn { row: replicaRow, sourceDbContext: readDbContext }\n\t\t}\n\t\tif (readDbContext.client.connection === dbContext.client.connection) {\n\t\t\treturn null\n\t\t}\n\t\t// A token issued moments ago (sign-in, createSessionToken) may not have reached the read replica yet.\n\t\tconst primaryRow = await dbContext.queryHandler.fetch(query)\n\t\treturn primaryRow !== null ? { row: primaryRow, sourceDbContext: dbContext } : null\n\t}\n\n\tasync createSessionApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<string> {\n\t\tconst { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo)\n\t\treturn token\n\t}\n\n\t/**\n\t * Like {@link createSessionApiKey} but also returns the api_key id — needed when the\n\t * caller has to bind another row to the session (e.g. an `idp_session` for IdP\n\t * re-validation).\n\t */\n\tasync createSessionApiKeyWithId(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<{ id: string; token: string }> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\n\t\t// A19: snapshot the effective session policy onto the api_key at sign-in.\n\t\t// With no matching policy, every field is null/baseline and the result is\n\t\t// byte-for-byte today's behavior.\n\t\tconst [identityRow] = await dbContext.queryHandler.fetch(new IdentityQuery([identityId]))\n\t\tconst globalRoles = identityRow?.roles ?? []\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, identityId, globalRoles)\n\n\t\tconst defaultExpirationMinutes = intervalToSeconds(config.login.defaultTokenExpiration) / 60\n\t\t// remember_me_allowed === false: ignore a client-supplied longer lifetime and\n\t\t// force the default. (true/null leave the requested expiration untouched.)\n\t\tconst requestedMinutes = policy.rememberMeAllowed === false\n\t\t\t? defaultExpirationMinutes\n\t\t\t: (expiration ?? defaultExpirationMinutes)\n\n\t\t// Cap by maxTokenExpiration (existing behavior) AND by the policy's\n\t\t// tokenExpiration (A19) — strictest wins.\n\t\tlet expirationCapped = config.login.maxTokenExpiration\n\t\t\t? Math.min(requestedMinutes, intervalToSeconds(config.login.maxTokenExpiration) / 60)\n\t\t\t: requestedMinutes\n\t\tif (policy.tokenExpiration) {\n\t\t\texpirationCapped = Math.min(expirationCapped, intervalToSeconds(policy.tokenExpiration) / 60)\n\t\t}\n\n\t\t// Absolute hard cap: issued_at + policy.tokenExpiration. NULL = uncapped\n\t\t// sliding window (today's behavior).\n\t\tconst issuedAt = dbContext.providers.now()\n\t\tconst maxExpiresAt = policy.tokenExpiration\n\t\t\t? new Date(issuedAt.getTime() + intervalToSeconds(policy.tokenExpiration) * 1000)\n\t\t\t: null\n\n\t\tconst command = new CreateApiKeyCommand({\n\t\t\ttype: ApiKey.Type.SESSION,\n\t\t\tidentityId,\n\t\t\texpiration: expirationCapped,\n\t\t\trequestInfo,\n\t\t\ttrustForwardedInfo,\n\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\tmaxExpiresAt,\n\t\t})\n\t\tconst { id, token } = await dbContext.commandBus.execute(command)\n\t\tassert(token !== undefined)\n\n\t\t// A19: audit only when a policy is actually in effect. With zero auth_policy\n\t\t// rows the resolved policy is inert and no extra insert happens.\n\t\tconst policyApplied = policy.mfaRequired === true\n\t\t\t|| policy.tokenExpiration !== null\n\t\t\t|| policy.idleTimeout !== null\n\t\t\t|| policy.rememberMeAllowed !== null\n\t\tif (policyApplied) {\n\t\t\ttry {\n\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\ttype: 'session_policy_applied',\n\t\t\t\t\tidentityId,\n\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\teventData: {\n\t\t\t\t\t\ttokenExpiration: policy.tokenExpiration ? intervalToPostgres(policy.tokenExpiration) : null,\n\t\t\t\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\t\t\t\trememberMeAllowed: policy.rememberMeAllowed,\n\t\t\t\t\t\tmfaRequired: policy.mfaRequired,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\t/* best-effort: sign-in must not break on an audit failure */\n\t\t\t}\n\t\t}\n\n\t\treturn { id, token }\n\t}\n\n\tasync findApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<ApiKeyRow | null> {\n\t\treturn await dbContext.queryHandler.fetch(\n\t\t\tnew ApiKeyByIdQuery(apiKeyId),\n\t\t)\n\t}\n\n\tasync disableOneOffApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableOneOffApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<boolean> {\n\t\treturn await dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableIdentityApiKeys(dbContext: DatabaseContext, identityId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableIdentityApiKeysCommand(identityId))\n\t}\n\n\tasync createGlobalPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tdescription: string,\n\t\troles: readonly string[],\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createPermanentApiKey(db, description, roles, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n\n\tasync createProjectPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tprojectId: string,\n\t\tmemberships: readonly Acl.Membership[],\n\t\tdescription: string,\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createProjectPermanentApiKey(db, projectId, memberships, description, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n}\n\ntype FetchedApiKey = {\n\trow: ApiKeyRow\n\tsourceDbContext: DatabaseContext\n}\n\nexport type VerifyResponse = Response<VerifyResult, VerifyErrorCode>\n\nexport class VerifyResult {\n\treadonly valid = true\n\n\tconstructor(\n\t\tpublic readonly identityId: string,\n\t\tpublic readonly apiKeyId: string,\n\t\tpublic readonly roles: string[],\n\t\tpublic readonly personId: string | null,\n\t\tpublic readonly trustForwardedInfo: boolean = false,\n\t) {}\n}\n\nexport enum VerifyErrorCode {\n\tNOT_FOUND = 'not_found',\n\tDISABLED = 'disabled',\n\tEXPIRED = 'expired',\n\tNO_AUTH_HEADER = 'no_auth_header',\n\tINVALID_AUTH_HEADER = 'invalid_auth_header',\n}\n"],"names":["UnpersistedApiKeyManager","ResponseOk","ResponseError","DisableApiKeyCommand","intervalToSeconds","PROLONG_THROTTLE_MS","ProlongApiKeyCommand","ApiKeyByTokenQuery","ConfigurationQuery","IdentityQuery","CreateApiKeyCommand","ApiKey","intervalToPostgres","ApiKeyByIdQuery","DisableOneOffApiKeyCommand","DisableIdentityApiKeysCommand","VerifyErrorCode"],"mappings":"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"}
|
|
@@ -34,10 +34,11 @@ class ApiKeyManager {
|
|
|
34
34
|
if (unpersistedResult !== null) {
|
|
35
35
|
return new ResponseOk(unpersistedResult);
|
|
36
36
|
}
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
37
|
+
const fetchedApiKey = await this.fetchApiKeyByToken(dbContext, readDbContext, token);
|
|
38
|
+
if (fetchedApiKey === null) {
|
|
39
39
|
return new ResponseError("not_found", "API key was not found");
|
|
40
40
|
}
|
|
41
|
+
const apiKeyRow = fetchedApiKey.row;
|
|
41
42
|
if (apiKeyRow.disabled_at !== null) {
|
|
42
43
|
return new ResponseError(
|
|
43
44
|
"disabled",
|
|
@@ -85,7 +86,7 @@ class ApiKeyManager {
|
|
|
85
86
|
userAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent
|
|
86
87
|
} : requestInfo;
|
|
87
88
|
if (this.idpSessionRevalidator) {
|
|
88
|
-
const outcome = await this.idpSessionRevalidator.revalidate(dbContext,
|
|
89
|
+
const outcome = await this.idpSessionRevalidator.revalidate(dbContext, fetchedApiKey.sourceDbContext, apiKeyRow, effectiveInfo);
|
|
89
90
|
if (outcome === "revoked") {
|
|
90
91
|
return new ResponseError("disabled", "IdP session was revoked");
|
|
91
92
|
}
|
|
@@ -118,6 +119,18 @@ class ApiKeyManager {
|
|
|
118
119
|
)
|
|
119
120
|
);
|
|
120
121
|
}
|
|
122
|
+
async fetchApiKeyByToken(dbContext, readDbContext, token) {
|
|
123
|
+
const query = new ApiKeyByTokenQuery(token);
|
|
124
|
+
const replicaRow = await readDbContext.queryHandler.fetch(query);
|
|
125
|
+
if (replicaRow !== null) {
|
|
126
|
+
return { row: replicaRow, sourceDbContext: readDbContext };
|
|
127
|
+
}
|
|
128
|
+
if (readDbContext.client.connection === dbContext.client.connection) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const primaryRow = await dbContext.queryHandler.fetch(query);
|
|
132
|
+
return primaryRow !== null ? { row: primaryRow, sourceDbContext: dbContext } : null;
|
|
133
|
+
}
|
|
121
134
|
async createSessionApiKey(dbContext, identityId, expiration, requestInfo, trustForwardedInfo) {
|
|
122
135
|
const { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo);
|
|
123
136
|
return token;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ApiKeyManager.js","sources":["../../../../../../../packages/engine-tenant-api/src/model/service/apiKey/ApiKeyManager.ts"],"sourcesContent":["import {\n\tApiKeyRequestInfo,\n\tCreateApiKeyCommand,\n\tDisableApiKeyCommand,\n\tDisableIdentityApiKeysCommand,\n\tDisableOneOffApiKeyCommand,\n\tPROLONG_THROTTLE_MS,\n\tProlongApiKeyCommand,\n} from '../../commands/index.js'\nimport { ApiKey } from '../../type/index.js'\n\nimport { Response, ResponseError, ResponseOk } from '../../utils/Response.js'\nimport { DatabaseContext, TokenHash } from '../../utils/index.js'\nimport { ApiKeyService, CreateApiKeyResponse } from './ApiKeyService.js'\nimport assert from 'node:assert'\nimport { Acl } from '@contember/schema'\nimport { ApiKeyByIdQuery, ApiKeyByTokenQuery, ApiKeyRow, ConfigurationQuery, IdentityQuery } from '../../queries/index.js'\nimport PostgresInterval from 'postgres-interval'\nimport { Config } from '../../type/Config.js'\nimport { intervalToPostgres, intervalToSeconds } from '../../utils/interval.js'\nimport { AuthPolicyResolver } from '../AuthPolicyResolver.js'\nimport { AuthLogService } from '../AuthLogService.js'\nimport { IdpSessionRevalidator } from '../idp/IdpSessionRevalidator.js'\nimport { UnpersistedApiKeyManager } from './UnpersistedApiKeyManager.js'\n\nexport class ApiKeyManager {\n\tconstructor(\n\t\tprivate readonly apiKeyService: ApiKeyService,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly authLogService: AuthLogService,\n\t\tprivate readonly unpersistedApiKeyManager: UnpersistedApiKeyManager = new UnpersistedApiKeyManager(),\n\t\tprivate readonly idpSessionRevalidator?: IdpSessionRevalidator,\n\t) {}\n\n\tasync verifyAndProlong(\n\t\tdbContext: DatabaseContext,\n\t\treadDbContext: DatabaseContext,\n\t\ttoken: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\tforwardedInfo?: ApiKeyRequestInfo,\n\t): Promise<VerifyResponse> {\n\t\tconst unpersistedResult = this.unpersistedApiKeyManager.verify(token)\n\t\tif (unpersistedResult !== null) {\n\t\t\t// Configured (unpersisted) root token: no DB row to prolong/track.\n\t\t\treturn new ResponseOk(unpersistedResult)\n\t\t}\n\n\t\tconst apiKeyRow = await readDbContext.queryHandler.fetch(new ApiKeyByTokenQuery(token))\n\t\tif (apiKeyRow === null) {\n\t\t\treturn new ResponseError(VerifyErrorCode.NOT_FOUND, 'API key was not found')\n\t\t}\n\n\t\tif (apiKeyRow.disabled_at !== null) {\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key was disabled at ${apiKeyRow.disabled_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// Single injectable clock for all session-policy time math (matches issuance,\n\t\t// which uses providers.now()), so the A19 idle/max logic is deterministically testable.\n\t\tconst now = dbContext.providers.now()\n\t\tif (apiKeyRow.expires_at !== null && apiKeyRow.expires_at <= now) {\n\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, `API key expired at ${apiKeyRow.expires_at.toISOString()}`)\n\t\t}\n\n\t\t// A19: absolute hard cap (defensive — the prolong clamp should already keep\n\t\t// expires_at <= max_expires_at, but enforce it here regardless).\n\t\tif (apiKeyRow.max_expires_at !== null && apiKeyRow.max_expires_at <= now) {\n\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key reached its maximum lifetime at ${apiKeyRow.max_expires_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// A19: idle timeout. Reject + disable when the session has been idle longer\n\t\t// than the policy's idle_timeout. A null idle_timeout (today's default) or a\n\t\t// never-used key (last_used_at null) are never idle-expired.\n\t\tif (apiKeyRow.idle_timeout !== null && apiKeyRow.last_used_at !== null) {\n\t\t\tconst idleMs = intervalToSeconds(apiKeyRow.idle_timeout) * 1000\n\t\t\t// last_used_at is written at most once per PROLONG_THROTTLE_MS (tracking is\n\t\t\t// throttled), so it can legitimately lag a continuously-active session by up\n\t\t\t// to that window. Add the throttle as slack so a small idle_timeout can't\n\t\t\t// idle-expire a session that is actually in use.\n\t\t\tif (now.getTime() - apiKeyRow.last_used_at.getTime() > idleMs + PROLONG_THROTTLE_MS) {\n\t\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\t\ttry {\n\t\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\t\ttype: 'session_expired_idle',\n\t\t\t\t\t\tidentityId: apiKeyRow.identity_id,\n\t\t\t\t\t\tpersonId: apiKeyRow.person_id,\n\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\teventData: {\n\t\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at?.toISOString() ?? null,\n\t\t\t\t\t\t\tidleTimeout: String(apiKeyRow.idle_timeout),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\t/* best-effort: auth path must stay resilient */\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t\t`API key was idle since ${apiKeyRow.last_used_at.toISOString()} and exceeded the idle timeout`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\tconst effectiveInfo: ApiKeyRequestInfo | undefined = apiKeyRow.trust_forwarded_info && forwardedInfo\n\t\t\t? {\n\t\t\t\tip: forwardedInfo.ip ?? requestInfo?.ip,\n\t\t\t\tuserAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent,\n\t\t\t}\n\t\t\t: requestInfo\n\n\t\t// Re-validate IdP-backed sessions against their identity provider (A24). In the\n\t\t// default `background` mode this returns immediately and revalidates after the\n\t\t// response (revocation lands on the next request); in `blocking` mode it can revoke\n\t\t// the current request. No-op for password sessions and IdPs without revalidation.\n\t\t// `effectiveInfo` is passed so the audit entries record the originating request's IP/UA.\n\t\tif (this.idpSessionRevalidator) {\n\t\t\tconst outcome = await this.idpSessionRevalidator.revalidate(dbContext, readDbContext, apiKeyRow, effectiveInfo)\n\t\t\tif (outcome === 'revoked') {\n\t\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, 'IdP session was revoked')\n\t\t\t}\n\t\t}\n\n\t\t// Detached (runs after the response), so a rejection here would be an unhandledRejection and can take the\n\t\t// worker down. Tracking is best-effort — the next request writes it again.\n\t\tsetImmediate(() => {\n\t\t\tdbContext.commandBus.execute(\n\t\t\t\tnew ProlongApiKeyCommand(\n\t\t\t\t\tapiKeyRow.id,\n\t\t\t\t\tapiKeyRow.type,\n\t\t\t\t\tapiKeyRow.expiration,\n\t\t\t\t\tapiKeyRow.expires_at,\n\t\t\t\t\teffectiveInfo,\n\t\t\t\t\t{\n\t\t\t\t\t\tlastIp: apiKeyRow.last_ip,\n\t\t\t\t\t\tlastUserAgent: apiKeyRow.last_user_agent,\n\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at,\n\t\t\t\t\t},\n\t\t\t\t\tapiKeyRow.max_expires_at,\n\t\t\t\t),\n\t\t\t).catch(() => {})\n\t\t})\n\n\t\treturn new ResponseOk(\n\t\t\tnew VerifyResult(\n\t\t\t\tapiKeyRow.identity_id,\n\t\t\t\tapiKeyRow.id,\n\t\t\t\tapiKeyRow.roles,\n\t\t\t\tapiKeyRow.person_id,\n\t\t\t\tapiKeyRow.trust_forwarded_info,\n\t\t\t),\n\t\t)\n\t}\n\n\tasync createSessionApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<string> {\n\t\tconst { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo)\n\t\treturn token\n\t}\n\n\t/**\n\t * Like {@link createSessionApiKey} but also returns the api_key id — needed when the\n\t * caller has to bind another row to the session (e.g. an `idp_session` for IdP\n\t * re-validation).\n\t */\n\tasync createSessionApiKeyWithId(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<{ id: string; token: string }> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\n\t\t// A19: snapshot the effective session policy onto the api_key at sign-in.\n\t\t// With no matching policy, every field is null/baseline and the result is\n\t\t// byte-for-byte today's behavior.\n\t\tconst [identityRow] = await dbContext.queryHandler.fetch(new IdentityQuery([identityId]))\n\t\tconst globalRoles = identityRow?.roles ?? []\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, identityId, globalRoles)\n\n\t\tconst defaultExpirationMinutes = intervalToSeconds(config.login.defaultTokenExpiration) / 60\n\t\t// remember_me_allowed === false: ignore a client-supplied longer lifetime and\n\t\t// force the default. (true/null leave the requested expiration untouched.)\n\t\tconst requestedMinutes = policy.rememberMeAllowed === false\n\t\t\t? defaultExpirationMinutes\n\t\t\t: (expiration ?? defaultExpirationMinutes)\n\n\t\t// Cap by maxTokenExpiration (existing behavior) AND by the policy's\n\t\t// tokenExpiration (A19) — strictest wins.\n\t\tlet expirationCapped = config.login.maxTokenExpiration\n\t\t\t? Math.min(requestedMinutes, intervalToSeconds(config.login.maxTokenExpiration) / 60)\n\t\t\t: requestedMinutes\n\t\tif (policy.tokenExpiration) {\n\t\t\texpirationCapped = Math.min(expirationCapped, intervalToSeconds(policy.tokenExpiration) / 60)\n\t\t}\n\n\t\t// Absolute hard cap: issued_at + policy.tokenExpiration. NULL = uncapped\n\t\t// sliding window (today's behavior).\n\t\tconst issuedAt = dbContext.providers.now()\n\t\tconst maxExpiresAt = policy.tokenExpiration\n\t\t\t? new Date(issuedAt.getTime() + intervalToSeconds(policy.tokenExpiration) * 1000)\n\t\t\t: null\n\n\t\tconst command = new CreateApiKeyCommand({\n\t\t\ttype: ApiKey.Type.SESSION,\n\t\t\tidentityId,\n\t\t\texpiration: expirationCapped,\n\t\t\trequestInfo,\n\t\t\ttrustForwardedInfo,\n\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\tmaxExpiresAt,\n\t\t})\n\t\tconst { id, token } = await dbContext.commandBus.execute(command)\n\t\tassert(token !== undefined)\n\n\t\t// A19: audit only when a policy is actually in effect. With zero auth_policy\n\t\t// rows the resolved policy is inert and no extra insert happens.\n\t\tconst policyApplied = policy.mfaRequired === true\n\t\t\t|| policy.tokenExpiration !== null\n\t\t\t|| policy.idleTimeout !== null\n\t\t\t|| policy.rememberMeAllowed !== null\n\t\tif (policyApplied) {\n\t\t\ttry {\n\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\ttype: 'session_policy_applied',\n\t\t\t\t\tidentityId,\n\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\teventData: {\n\t\t\t\t\t\ttokenExpiration: policy.tokenExpiration ? intervalToPostgres(policy.tokenExpiration) : null,\n\t\t\t\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\t\t\t\trememberMeAllowed: policy.rememberMeAllowed,\n\t\t\t\t\t\tmfaRequired: policy.mfaRequired,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\t/* best-effort: sign-in must not break on an audit failure */\n\t\t\t}\n\t\t}\n\n\t\treturn { id, token }\n\t}\n\n\tasync findApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<ApiKeyRow | null> {\n\t\treturn await dbContext.queryHandler.fetch(\n\t\t\tnew ApiKeyByIdQuery(apiKeyId),\n\t\t)\n\t}\n\n\tasync disableOneOffApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableOneOffApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<boolean> {\n\t\treturn await dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableIdentityApiKeys(dbContext: DatabaseContext, identityId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableIdentityApiKeysCommand(identityId))\n\t}\n\n\tasync createGlobalPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tdescription: string,\n\t\troles: readonly string[],\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createPermanentApiKey(db, description, roles, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n\n\tasync createProjectPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tprojectId: string,\n\t\tmemberships: readonly Acl.Membership[],\n\t\tdescription: string,\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createProjectPermanentApiKey(db, projectId, memberships, description, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n}\n\nexport type VerifyResponse = Response<VerifyResult, VerifyErrorCode>\n\nexport class VerifyResult {\n\treadonly valid = true\n\n\tconstructor(\n\t\tpublic readonly identityId: string,\n\t\tpublic readonly apiKeyId: string,\n\t\tpublic readonly roles: string[],\n\t\tpublic readonly personId: string | null,\n\t\tpublic readonly trustForwardedInfo: boolean = false,\n\t) {}\n}\n\nexport enum VerifyErrorCode {\n\tNOT_FOUND = 'not_found',\n\tDISABLED = 'disabled',\n\tEXPIRED = 'expired',\n\tNO_AUTH_HEADER = 'no_auth_header',\n\tINVALID_AUTH_HEADER = 'invalid_auth_header',\n}\n"],"names":["VerifyErrorCode"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ApiKeyManager.js","sources":["../../../../../../../packages/engine-tenant-api/src/model/service/apiKey/ApiKeyManager.ts"],"sourcesContent":["import {\n\tApiKeyRequestInfo,\n\tCreateApiKeyCommand,\n\tDisableApiKeyCommand,\n\tDisableIdentityApiKeysCommand,\n\tDisableOneOffApiKeyCommand,\n\tPROLONG_THROTTLE_MS,\n\tProlongApiKeyCommand,\n} from '../../commands/index.js'\nimport { ApiKey } from '../../type/index.js'\n\nimport { Response, ResponseError, ResponseOk } from '../../utils/Response.js'\nimport { DatabaseContext, TokenHash } from '../../utils/index.js'\nimport { ApiKeyService, CreateApiKeyResponse } from './ApiKeyService.js'\nimport assert from 'node:assert'\nimport { Acl } from '@contember/schema'\nimport { ApiKeyByIdQuery, ApiKeyByTokenQuery, ApiKeyRow, ConfigurationQuery, IdentityQuery } from '../../queries/index.js'\nimport PostgresInterval from 'postgres-interval'\nimport { Config } from '../../type/Config.js'\nimport { intervalToPostgres, intervalToSeconds } from '../../utils/interval.js'\nimport { AuthPolicyResolver } from '../AuthPolicyResolver.js'\nimport { AuthLogService } from '../AuthLogService.js'\nimport { IdpSessionRevalidator } from '../idp/IdpSessionRevalidator.js'\nimport { UnpersistedApiKeyManager } from './UnpersistedApiKeyManager.js'\n\nexport class ApiKeyManager {\n\tconstructor(\n\t\tprivate readonly apiKeyService: ApiKeyService,\n\t\tprivate readonly authPolicyResolver: AuthPolicyResolver,\n\t\tprivate readonly authLogService: AuthLogService,\n\t\tprivate readonly unpersistedApiKeyManager: UnpersistedApiKeyManager = new UnpersistedApiKeyManager(),\n\t\tprivate readonly idpSessionRevalidator?: IdpSessionRevalidator,\n\t) {}\n\n\tasync verifyAndProlong(\n\t\tdbContext: DatabaseContext,\n\t\treadDbContext: DatabaseContext,\n\t\ttoken: string,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\tforwardedInfo?: ApiKeyRequestInfo,\n\t): Promise<VerifyResponse> {\n\t\tconst unpersistedResult = this.unpersistedApiKeyManager.verify(token)\n\t\tif (unpersistedResult !== null) {\n\t\t\t// Configured (unpersisted) root token: no DB row to prolong/track.\n\t\t\treturn new ResponseOk(unpersistedResult)\n\t\t}\n\n\t\tconst fetchedApiKey = await this.fetchApiKeyByToken(dbContext, readDbContext, token)\n\t\tif (fetchedApiKey === null) {\n\t\t\treturn new ResponseError(VerifyErrorCode.NOT_FOUND, 'API key was not found')\n\t\t}\n\t\tconst apiKeyRow = fetchedApiKey.row\n\n\t\tif (apiKeyRow.disabled_at !== null) {\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key was disabled at ${apiKeyRow.disabled_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// Single injectable clock for all session-policy time math (matches issuance,\n\t\t// which uses providers.now()), so the A19 idle/max logic is deterministically testable.\n\t\tconst now = dbContext.providers.now()\n\t\tif (apiKeyRow.expires_at !== null && apiKeyRow.expires_at <= now) {\n\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, `API key expired at ${apiKeyRow.expires_at.toISOString()}`)\n\t\t}\n\n\t\t// A19: absolute hard cap (defensive — the prolong clamp should already keep\n\t\t// expires_at <= max_expires_at, but enforce it here regardless).\n\t\tif (apiKeyRow.max_expires_at !== null && apiKeyRow.max_expires_at <= now) {\n\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\treturn new ResponseError(\n\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t`API key reached its maximum lifetime at ${apiKeyRow.max_expires_at.toISOString()}`,\n\t\t\t)\n\t\t}\n\n\t\t// A19: idle timeout. Reject + disable when the session has been idle longer\n\t\t// than the policy's idle_timeout. A null idle_timeout (today's default) or a\n\t\t// never-used key (last_used_at null) are never idle-expired.\n\t\tif (apiKeyRow.idle_timeout !== null && apiKeyRow.last_used_at !== null) {\n\t\t\tconst idleMs = intervalToSeconds(apiKeyRow.idle_timeout) * 1000\n\t\t\t// last_used_at is written at most once per PROLONG_THROTTLE_MS (tracking is\n\t\t\t// throttled), so it can legitimately lag a continuously-active session by up\n\t\t\t// to that window. Add the throttle as slack so a small idle_timeout can't\n\t\t\t// idle-expire a session that is actually in use.\n\t\t\tif (now.getTime() - apiKeyRow.last_used_at.getTime() > idleMs + PROLONG_THROTTLE_MS) {\n\t\t\t\tawait dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))\n\t\t\t\ttry {\n\t\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\t\ttype: 'session_expired_idle',\n\t\t\t\t\t\tidentityId: apiKeyRow.identity_id,\n\t\t\t\t\t\tpersonId: apiKeyRow.person_id,\n\t\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\teventData: {\n\t\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at?.toISOString() ?? null,\n\t\t\t\t\t\t\tidleTimeout: String(apiKeyRow.idle_timeout),\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\t/* best-effort: auth path must stay resilient */\n\t\t\t\t}\n\t\t\t\treturn new ResponseError(\n\t\t\t\t\tVerifyErrorCode.DISABLED,\n\t\t\t\t\t`API key was idle since ${apiKeyRow.last_used_at.toISOString()} and exceeded the idle timeout`,\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\n\t\tconst effectiveInfo: ApiKeyRequestInfo | undefined = apiKeyRow.trust_forwarded_info && forwardedInfo\n\t\t\t? {\n\t\t\t\tip: forwardedInfo.ip ?? requestInfo?.ip,\n\t\t\t\tuserAgent: forwardedInfo.userAgent ?? requestInfo?.userAgent,\n\t\t\t}\n\t\t\t: requestInfo\n\n\t\t// Re-validate IdP-backed sessions against their identity provider (A24). In the\n\t\t// default `background` mode this returns immediately and revalidates after the\n\t\t// response (revocation lands on the next request); in `blocking` mode it can revoke\n\t\t// the current request. No-op for password sessions and IdPs without revalidation.\n\t\t// `effectiveInfo` is passed so the audit entries record the originating request's IP/UA.\n\t\t// The idp_session row is written together with the api_key, so it is read from where the key was found.\n\t\tif (this.idpSessionRevalidator) {\n\t\t\tconst outcome = await this.idpSessionRevalidator.revalidate(dbContext, fetchedApiKey.sourceDbContext, apiKeyRow, effectiveInfo)\n\t\t\tif (outcome === 'revoked') {\n\t\t\t\treturn new ResponseError(VerifyErrorCode.DISABLED, 'IdP session was revoked')\n\t\t\t}\n\t\t}\n\n\t\t// Detached (runs after the response), so a rejection here would be an unhandledRejection and can take the\n\t\t// worker down. Tracking is best-effort — the next request writes it again.\n\t\tsetImmediate(() => {\n\t\t\tdbContext.commandBus.execute(\n\t\t\t\tnew ProlongApiKeyCommand(\n\t\t\t\t\tapiKeyRow.id,\n\t\t\t\t\tapiKeyRow.type,\n\t\t\t\t\tapiKeyRow.expiration,\n\t\t\t\t\tapiKeyRow.expires_at,\n\t\t\t\t\teffectiveInfo,\n\t\t\t\t\t{\n\t\t\t\t\t\tlastIp: apiKeyRow.last_ip,\n\t\t\t\t\t\tlastUserAgent: apiKeyRow.last_user_agent,\n\t\t\t\t\t\tlastUsedAt: apiKeyRow.last_used_at,\n\t\t\t\t\t},\n\t\t\t\t\tapiKeyRow.max_expires_at,\n\t\t\t\t),\n\t\t\t).catch(() => {})\n\t\t})\n\n\t\treturn new ResponseOk(\n\t\t\tnew VerifyResult(\n\t\t\t\tapiKeyRow.identity_id,\n\t\t\t\tapiKeyRow.id,\n\t\t\t\tapiKeyRow.roles,\n\t\t\t\tapiKeyRow.person_id,\n\t\t\t\tapiKeyRow.trust_forwarded_info,\n\t\t\t),\n\t\t)\n\t}\n\n\tprivate async fetchApiKeyByToken(dbContext: DatabaseContext, readDbContext: DatabaseContext, token: string): Promise<FetchedApiKey | null> {\n\t\tconst query = new ApiKeyByTokenQuery(token)\n\t\tconst replicaRow = await readDbContext.queryHandler.fetch(query)\n\t\tif (replicaRow !== null) {\n\t\t\treturn { row: replicaRow, sourceDbContext: readDbContext }\n\t\t}\n\t\tif (readDbContext.client.connection === dbContext.client.connection) {\n\t\t\treturn null\n\t\t}\n\t\t// A token issued moments ago (sign-in, createSessionToken) may not have reached the read replica yet.\n\t\tconst primaryRow = await dbContext.queryHandler.fetch(query)\n\t\treturn primaryRow !== null ? { row: primaryRow, sourceDbContext: dbContext } : null\n\t}\n\n\tasync createSessionApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<string> {\n\t\tconst { token } = await this.createSessionApiKeyWithId(dbContext, identityId, expiration, requestInfo, trustForwardedInfo)\n\t\treturn token\n\t}\n\n\t/**\n\t * Like {@link createSessionApiKey} but also returns the api_key id — needed when the\n\t * caller has to bind another row to the session (e.g. an `idp_session` for IdP\n\t * re-validation).\n\t */\n\tasync createSessionApiKeyWithId(\n\t\tdbContext: DatabaseContext,\n\t\tidentityId: string,\n\t\texpiration?: number,\n\t\trequestInfo?: ApiKeyRequestInfo,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<{ id: string; token: string }> {\n\t\tconst config = await dbContext.queryHandler.fetch(new ConfigurationQuery(dbContext.providers))\n\n\t\t// A19: snapshot the effective session policy onto the api_key at sign-in.\n\t\t// With no matching policy, every field is null/baseline and the result is\n\t\t// byte-for-byte today's behavior.\n\t\tconst [identityRow] = await dbContext.queryHandler.fetch(new IdentityQuery([identityId]))\n\t\tconst globalRoles = identityRow?.roles ?? []\n\t\tconst policy = await this.authPolicyResolver.resolveForIdentity(dbContext, identityId, globalRoles)\n\n\t\tconst defaultExpirationMinutes = intervalToSeconds(config.login.defaultTokenExpiration) / 60\n\t\t// remember_me_allowed === false: ignore a client-supplied longer lifetime and\n\t\t// force the default. (true/null leave the requested expiration untouched.)\n\t\tconst requestedMinutes = policy.rememberMeAllowed === false\n\t\t\t? defaultExpirationMinutes\n\t\t\t: (expiration ?? defaultExpirationMinutes)\n\n\t\t// Cap by maxTokenExpiration (existing behavior) AND by the policy's\n\t\t// tokenExpiration (A19) — strictest wins.\n\t\tlet expirationCapped = config.login.maxTokenExpiration\n\t\t\t? Math.min(requestedMinutes, intervalToSeconds(config.login.maxTokenExpiration) / 60)\n\t\t\t: requestedMinutes\n\t\tif (policy.tokenExpiration) {\n\t\t\texpirationCapped = Math.min(expirationCapped, intervalToSeconds(policy.tokenExpiration) / 60)\n\t\t}\n\n\t\t// Absolute hard cap: issued_at + policy.tokenExpiration. NULL = uncapped\n\t\t// sliding window (today's behavior).\n\t\tconst issuedAt = dbContext.providers.now()\n\t\tconst maxExpiresAt = policy.tokenExpiration\n\t\t\t? new Date(issuedAt.getTime() + intervalToSeconds(policy.tokenExpiration) * 1000)\n\t\t\t: null\n\n\t\tconst command = new CreateApiKeyCommand({\n\t\t\ttype: ApiKey.Type.SESSION,\n\t\t\tidentityId,\n\t\t\texpiration: expirationCapped,\n\t\t\trequestInfo,\n\t\t\ttrustForwardedInfo,\n\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\tmaxExpiresAt,\n\t\t})\n\t\tconst { id, token } = await dbContext.commandBus.execute(command)\n\t\tassert(token !== undefined)\n\n\t\t// A19: audit only when a policy is actually in effect. With zero auth_policy\n\t\t// rows the resolved policy is inert and no extra insert happens.\n\t\tconst policyApplied = policy.mfaRequired === true\n\t\t\t|| policy.tokenExpiration !== null\n\t\t\t|| policy.idleTimeout !== null\n\t\t\t|| policy.rememberMeAllowed !== null\n\t\tif (policyApplied) {\n\t\t\ttry {\n\t\t\t\tawait this.authLogService.logSessionEvent(dbContext, {\n\t\t\t\t\ttype: 'session_policy_applied',\n\t\t\t\t\tidentityId,\n\t\t\t\t\tipAddress: requestInfo?.ip,\n\t\t\t\t\tuserAgent: requestInfo?.userAgent,\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\teventData: {\n\t\t\t\t\t\ttokenExpiration: policy.tokenExpiration ? intervalToPostgres(policy.tokenExpiration) : null,\n\t\t\t\t\t\tidleTimeout: policy.idleTimeout ? intervalToPostgres(policy.idleTimeout) : null,\n\t\t\t\t\t\trememberMeAllowed: policy.rememberMeAllowed,\n\t\t\t\t\t\tmfaRequired: policy.mfaRequired,\n\t\t\t\t\t},\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\t/* best-effort: sign-in must not break on an audit failure */\n\t\t\t}\n\t\t}\n\n\t\treturn { id, token }\n\t}\n\n\tasync findApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<ApiKeyRow | null> {\n\t\treturn await dbContext.queryHandler.fetch(\n\t\t\tnew ApiKeyByIdQuery(apiKeyId),\n\t\t)\n\t}\n\n\tasync disableOneOffApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableOneOffApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableApiKey(dbContext: DatabaseContext, apiKeyId: string): Promise<boolean> {\n\t\treturn await dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyId))\n\t}\n\n\tasync disableIdentityApiKeys(dbContext: DatabaseContext, identityId: string): Promise<void> {\n\t\tawait dbContext.commandBus.execute(new DisableIdentityApiKeysCommand(identityId))\n\t}\n\n\tasync createGlobalPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tdescription: string,\n\t\troles: readonly string[],\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createPermanentApiKey(db, description, roles, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n\n\tasync createProjectPermanentApiKey(\n\t\tdbContext: DatabaseContext,\n\t\tprojectId: string,\n\t\tmemberships: readonly Acl.Membership[],\n\t\tdescription: string,\n\t\ttokenHash?: TokenHash,\n\t\ttrustForwardedInfo?: boolean,\n\t): Promise<CreateApiKeyResponse> {\n\t\treturn await dbContext.transaction(async db => {\n\t\t\treturn await this.apiKeyService.createProjectPermanentApiKey(db, projectId, memberships, description, tokenHash, trustForwardedInfo)\n\t\t})\n\t}\n}\n\ntype FetchedApiKey = {\n\trow: ApiKeyRow\n\tsourceDbContext: DatabaseContext\n}\n\nexport type VerifyResponse = Response<VerifyResult, VerifyErrorCode>\n\nexport class VerifyResult {\n\treadonly valid = true\n\n\tconstructor(\n\t\tpublic readonly identityId: string,\n\t\tpublic readonly apiKeyId: string,\n\t\tpublic readonly roles: string[],\n\t\tpublic readonly personId: string | null,\n\t\tpublic readonly trustForwardedInfo: boolean = false,\n\t) {}\n}\n\nexport enum VerifyErrorCode {\n\tNOT_FOUND = 'not_found',\n\tDISABLED = 'disabled',\n\tEXPIRED = 'expired',\n\tNO_AUTH_HEADER = 'no_auth_header',\n\tINVALID_AUTH_HEADER = 'invalid_auth_header',\n}\n"],"names":["VerifyErrorCode"],"mappings":"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"}
|