@learncard/lca-api-service 1.4.0 → 1.5.0
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/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +43 -0
- package/bootstrapLambda.test.ts +76 -0
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +23 -13
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +172 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
|
|
3
|
+
import cache from '@cache';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_FAILED_ATTEMPTS = 50;
|
|
6
|
+
export const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 10 * 60;
|
|
7
|
+
|
|
8
|
+
// Repair legacy counters during reads too: an already-blocked caller never reaches recordFailure.
|
|
9
|
+
const READ_ATTEMPTS = `
|
|
10
|
+
local attempts = redis.call('GET', KEYS[1])
|
|
11
|
+
if attempts and redis.call('TTL', KEYS[1]) < 0 then
|
|
12
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
13
|
+
end
|
|
14
|
+
return attempts
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const RECORD_FAILURE = `
|
|
18
|
+
local attempts = redis.call('INCR', KEYS[1])
|
|
19
|
+
if redis.call('TTL', KEYS[1]) < 0 then
|
|
20
|
+
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
|
21
|
+
end
|
|
22
|
+
return attempts
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Failures-only rate limiting. Callers check `isRateLimited` up front and call
|
|
27
|
+
* `recordFailure` only on rejected attempts, so legitimate traffic behind a
|
|
28
|
+
* shared NAT never counts toward the ceiling. Increment and TTL repair run in
|
|
29
|
+
* one script without extending an existing window.
|
|
30
|
+
*
|
|
31
|
+
* Both halves fail open: if Redis is unreachable, reads return false and
|
|
32
|
+
* `recordFailure` logs and returns
|
|
33
|
+
* rather than turning a rejected login attempt into a 500. Limiting is a
|
|
34
|
+
* brute-force speed bump, not the access control — that is the ticket / code /
|
|
35
|
+
* client secret entropy.
|
|
36
|
+
*/
|
|
37
|
+
export const isRateLimited = async (
|
|
38
|
+
key: string,
|
|
39
|
+
max = DEFAULT_MAX_FAILED_ATTEMPTS
|
|
40
|
+
): Promise<boolean> => {
|
|
41
|
+
const redis = cache.redis ?? cache.node;
|
|
42
|
+
try {
|
|
43
|
+
const attempts =
|
|
44
|
+
Number(await redis.eval(READ_ATTEMPTS, 1, key, DEFAULT_RATE_LIMIT_WINDOW_SECONDS)) || 0;
|
|
45
|
+
return attempts >= max;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const recordFailure = async (
|
|
52
|
+
key: string,
|
|
53
|
+
windowSeconds = DEFAULT_RATE_LIMIT_WINDOW_SECONDS
|
|
54
|
+
): Promise<void> => {
|
|
55
|
+
const redis = cache.redis ?? cache.node;
|
|
56
|
+
try {
|
|
57
|
+
await redis.eval(RECORD_FAILURE, 1, key, windowSeconds);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Rate limit recordFailure error', error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Client address for rate-limit keys.
|
|
65
|
+
*
|
|
66
|
+
* `request.ip` is the transport-level peer: under `serverless-http` it is API
|
|
67
|
+
* Gateway's `requestContext.http.sourceIp`, which the caller cannot forge. The
|
|
68
|
+
* `x-forwarded-for` header is NOT trusted — API Gateway appends the real source
|
|
69
|
+
* to whatever the client sent, so the first hop is attacker-controlled and would
|
|
70
|
+
* let anyone mint a fresh per-IP bucket per request.
|
|
71
|
+
*/
|
|
72
|
+
export const getRequestClientIp = (request: Pick<FastifyRequest, 'ip'>): string =>
|
|
73
|
+
request.ip || 'unknown';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Google / Apple `id_token` verification for the lca-api auth router
|
|
3
|
+
* (AD-2 / AD-3).
|
|
4
|
+
*
|
|
5
|
+
* The native app obtains an Apple/Google `id_token` through the platform SDK
|
|
6
|
+
* and posts it here. We verify it against the provider's published JWKS — exact
|
|
7
|
+
* issuer + audience (client id) allowlists, signature, `exp`/`nbf` — before
|
|
8
|
+
* issuing a login ticket. A test seam (`setSocialJwksResolverForTests`) swaps
|
|
9
|
+
* the remote JWKS for a local key set so the flow is unit-testable without
|
|
10
|
+
* network access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { environment } from '@environment';
|
|
14
|
+
import { TRPCError } from '@trpc/server';
|
|
15
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
16
|
+
import type { JWTVerifyGetKey } from 'jose';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export type SocialProviderId = 'google' | 'apple';
|
|
20
|
+
|
|
21
|
+
export interface SocialProviderConfig {
|
|
22
|
+
issuers: string[];
|
|
23
|
+
audiences: string[];
|
|
24
|
+
jwksUri: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SocialClaims {
|
|
28
|
+
sub: string;
|
|
29
|
+
iss: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
email_verified?: boolean;
|
|
32
|
+
name?: string;
|
|
33
|
+
picture?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const GOOGLE_DEFAULT_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
|
|
37
|
+
const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
|
|
38
|
+
const APPLE_DEFAULT_ISSUERS = ['https://appleid.apple.com'];
|
|
39
|
+
const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
|
|
40
|
+
|
|
41
|
+
const claimsSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
sub: z.string().min(1),
|
|
44
|
+
iss: z.string().min(1),
|
|
45
|
+
email: z.string().min(1).optional(),
|
|
46
|
+
email_verified: z.union([z.boolean(), z.string()]).optional(),
|
|
47
|
+
name: z.string().optional(),
|
|
48
|
+
picture: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
.passthrough();
|
|
51
|
+
|
|
52
|
+
const splitCsv = (value: string | undefined): string[] =>
|
|
53
|
+
(value ?? '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map(entry => entry.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
export const getSocialProviderConfig = (
|
|
59
|
+
provider: SocialProviderId
|
|
60
|
+
): SocialProviderConfig | null => {
|
|
61
|
+
if (provider === 'google') {
|
|
62
|
+
const audiences = splitCsv(environment.GOOGLE_OAUTH_CLIENT_IDS);
|
|
63
|
+
if (audiences.length === 0) return null;
|
|
64
|
+
return {
|
|
65
|
+
audiences,
|
|
66
|
+
issuers: GOOGLE_DEFAULT_ISSUERS,
|
|
67
|
+
jwksUri: GOOGLE_JWKS_URI,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const audiences = splitCsv(environment.APPLE_OAUTH_CLIENT_IDS);
|
|
72
|
+
if (audiences.length === 0) return null;
|
|
73
|
+
return {
|
|
74
|
+
audiences,
|
|
75
|
+
issuers: APPLE_DEFAULT_ISSUERS,
|
|
76
|
+
jwksUri: APPLE_JWKS_URI,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const jwksByUri = new Map<string, JWTVerifyGetKey>();
|
|
81
|
+
let resolverForTests: ((jwksUri: string) => JWTVerifyGetKey) | undefined;
|
|
82
|
+
|
|
83
|
+
export const setSocialJwksResolverForTests = (
|
|
84
|
+
resolver: ((jwksUri: string) => JWTVerifyGetKey) | undefined
|
|
85
|
+
): void => {
|
|
86
|
+
resolverForTests = resolver;
|
|
87
|
+
jwksByUri.clear();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const getSocialJwks = (jwksUri: string): JWTVerifyGetKey => {
|
|
91
|
+
let jwks = jwksByUri.get(jwksUri);
|
|
92
|
+
if (!jwks) {
|
|
93
|
+
jwks = resolverForTests ? resolverForTests(jwksUri) : createRemoteJWKSet(new URL(jwksUri));
|
|
94
|
+
jwksByUri.set(jwksUri, jwks);
|
|
95
|
+
}
|
|
96
|
+
return jwks;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const verifySocialIdToken = async (
|
|
100
|
+
provider: SocialProviderId,
|
|
101
|
+
token: string
|
|
102
|
+
): Promise<SocialClaims> => {
|
|
103
|
+
const config = getSocialProviderConfig(provider);
|
|
104
|
+
if (!config) {
|
|
105
|
+
throw new TRPCError({
|
|
106
|
+
code: 'UNAUTHORIZED',
|
|
107
|
+
message: `Sign-in with ${provider} is not configured.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload: unknown;
|
|
112
|
+
try {
|
|
113
|
+
({ payload } = await jwtVerify(token, getSocialJwks(config.jwksUri), {
|
|
114
|
+
issuer: config.issuers,
|
|
115
|
+
audience: config.audiences,
|
|
116
|
+
algorithms: ['RS256', 'ES256'],
|
|
117
|
+
requiredClaims: ['sub', 'exp', 'email', 'email_verified'],
|
|
118
|
+
}));
|
|
119
|
+
} catch {
|
|
120
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = claimsSchema.safeParse(payload);
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const claims = result.data;
|
|
129
|
+
const emailVerified =
|
|
130
|
+
claims.email_verified === true ||
|
|
131
|
+
(provider === 'apple' && claims.email_verified === 'true');
|
|
132
|
+
if (!claims.email || !emailVerified) {
|
|
133
|
+
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
sub: claims.sub,
|
|
138
|
+
iss: claims.iss,
|
|
139
|
+
email: claims.email,
|
|
140
|
+
email_verified: emailVerified,
|
|
141
|
+
name: claims.name,
|
|
142
|
+
picture: claims.picture,
|
|
143
|
+
};
|
|
144
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findPendingEscrowHoldByAuthProvider,
|
|
3
|
+
findUserKeysWithEscrowBlobKeyId,
|
|
4
|
+
rewrapEscrowBlobByAuthProvider,
|
|
5
|
+
} from '@models';
|
|
6
|
+
import {
|
|
7
|
+
getEnclaveAttestationIdentity,
|
|
8
|
+
getEscrowEnclave,
|
|
9
|
+
isEscrowEnabled,
|
|
10
|
+
} from '../services/escrow-enclave';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_REWRAP_BATCH_LIMIT = 200;
|
|
13
|
+
|
|
14
|
+
export interface EscrowBlobRewrapResult {
|
|
15
|
+
rewrapped: number;
|
|
16
|
+
skippedPendingHold: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunEscrowBlobRewrapOptions {
|
|
21
|
+
limit?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deliberately drops the caught error's message, not just its stack: a real
|
|
26
|
+
* enclave/decrypt failure could embed plaintext or a proof in its own
|
|
27
|
+
* message text, so forwarding it here would leak secrets through the one
|
|
28
|
+
* logging path meant to report the failure. Mirrors
|
|
29
|
+
* `escrowHoldReminders.ts`'s `logJobError`, which makes the same trade-off
|
|
30
|
+
* for the same reason. `userKeyId` (a Mongo id, not a DID/email) is safe to
|
|
31
|
+
* log for triage.
|
|
32
|
+
*/
|
|
33
|
+
const logJobError = (userKeyId: string | undefined, error: unknown): void => {
|
|
34
|
+
console.error('[escrow-blob-rewrap] failed to process user key', {
|
|
35
|
+
userKeyId,
|
|
36
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scheduled maintenance job (P9.3): migrates escrow blobs still sealed
|
|
42
|
+
* under a previous enclave key onto the current key, so that key can
|
|
43
|
+
* eventually be retired. Covers accounts that never sign in again after a
|
|
44
|
+
* key rotation — those already migrate client-side on next login via P9.2's
|
|
45
|
+
* carry-on-enroll path.
|
|
46
|
+
*
|
|
47
|
+
* No-ops when escrow is disabled for this stage/tenant, or when the
|
|
48
|
+
* enclave's current (cached) identity reports no previous keys at all —
|
|
49
|
+
* the common case once every previous key has been fully retired. Skips
|
|
50
|
+
* (does not touch) any account with a pending recovery hold: the signed
|
|
51
|
+
* hold record binds the OLD blob's hash/epoch, so re-sealing mid-hold would
|
|
52
|
+
* break an in-progress recovery; those accounts are simply re-tried on a
|
|
53
|
+
* later run once their hold resolves. Every write is an atomic
|
|
54
|
+
* compare-and-swap (`rewrapEscrowBlobByAuthProvider`); losing that race to a
|
|
55
|
+
* concurrent re-enroll/carry/opt-out is a silent no-op, not a failure.
|
|
56
|
+
*/
|
|
57
|
+
export const runEscrowBlobRewrap = async (
|
|
58
|
+
options: RunEscrowBlobRewrapOptions = {}
|
|
59
|
+
): Promise<EscrowBlobRewrapResult> => {
|
|
60
|
+
const empty: EscrowBlobRewrapResult = { rewrapped: 0, skippedPendingHold: 0, failed: 0 };
|
|
61
|
+
if (!isEscrowEnabled()) return empty;
|
|
62
|
+
|
|
63
|
+
// Cheap, cache-aware gate: avoids a real enclave round trip on every
|
|
64
|
+
// run once there is nothing left to migrate (see services/escrow-enclave/
|
|
65
|
+
// index.ts's `getEnclaveAttestationIdentity`).
|
|
66
|
+
const identity = await getEnclaveAttestationIdentity().catch(() => undefined);
|
|
67
|
+
if (!identity || identity.previousKeyIds.length === 0) return empty;
|
|
68
|
+
|
|
69
|
+
const limit = options.limit ?? DEFAULT_REWRAP_BATCH_LIMIT;
|
|
70
|
+
const candidates = await findUserKeysWithEscrowBlobKeyId(identity.previousKeyIds, limit);
|
|
71
|
+
if (candidates.length === 0) return empty;
|
|
72
|
+
|
|
73
|
+
// A fresh, full attestation call (not just the cached identity) for the
|
|
74
|
+
// measurements the migrated blob must carry; only fetched once there is
|
|
75
|
+
// actually something to migrate this run.
|
|
76
|
+
const attestation = await getEscrowEnclave().getAttestation();
|
|
77
|
+
|
|
78
|
+
let rewrapped = 0;
|
|
79
|
+
let skippedPendingHold = 0;
|
|
80
|
+
let failed = 0;
|
|
81
|
+
|
|
82
|
+
for (const userKey of candidates) {
|
|
83
|
+
const blob = userKey.escrowBlob;
|
|
84
|
+
const authProvider = userKey.authProviders[0];
|
|
85
|
+
// Already migrated (by this loop's own attestation refresh landing
|
|
86
|
+
// mid-batch, or a concurrent run) since the candidate query ran.
|
|
87
|
+
if (!blob || !authProvider || blob.enclaveKeyId === attestation.keyId) continue;
|
|
88
|
+
try {
|
|
89
|
+
if (await findPendingEscrowHoldByAuthProvider(authProvider)) {
|
|
90
|
+
skippedPendingHold += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const { envelope } = await getEscrowEnclave().rewrapEscrowBlob({
|
|
94
|
+
envelope: blob.envelope,
|
|
95
|
+
expectedDid: userKey.primaryDid,
|
|
96
|
+
expectedShareVersion: blob.shareVersion,
|
|
97
|
+
sourceEnrollmentEpoch: blob.enrollmentEpoch,
|
|
98
|
+
});
|
|
99
|
+
const stored = await rewrapEscrowBlobByAuthProvider(
|
|
100
|
+
authProvider,
|
|
101
|
+
blob,
|
|
102
|
+
userKey.escrowPin,
|
|
103
|
+
envelope,
|
|
104
|
+
{
|
|
105
|
+
enclaveKeyId: attestation.keyId,
|
|
106
|
+
enclaveMode: attestation.mode,
|
|
107
|
+
measurements: attestation.measurements,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
if (stored) rewrapped += 1;
|
|
111
|
+
// else: lost the compare-and-swap race (a concurrent re-enroll,
|
|
112
|
+
// PIN change or opt-out since this candidate was read) — not a
|
|
113
|
+
// failure, just re-tried on a later run if still applicable.
|
|
114
|
+
} catch (error) {
|
|
115
|
+
failed += 1;
|
|
116
|
+
logJobError(userKey._id, error);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { rewrapped, skippedPendingHold, failed };
|
|
121
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
|
|
2
|
+
import {
|
|
3
|
+
expireStaleEscrowHolds,
|
|
4
|
+
findEscrowHoldsPendingEnclaveCancellation,
|
|
5
|
+
countEscrowCancellationsNearRelease,
|
|
6
|
+
findEscrowHoldsDueForReminder,
|
|
7
|
+
claimEscrowHoldForReminder,
|
|
8
|
+
findUserKeyByAuthProvider,
|
|
9
|
+
findEscrowHoldsPendingStartNotification,
|
|
10
|
+
releaseEscrowReminderClaim,
|
|
11
|
+
recordEscrowStartNotificationAttempt,
|
|
12
|
+
} from '@models';
|
|
13
|
+
import { environment } from '@environment';
|
|
14
|
+
import {
|
|
15
|
+
cancelHoldInEnclave,
|
|
16
|
+
logEscrowCancellationPending,
|
|
17
|
+
} from '../services/escrow-enclave/cancellation';
|
|
18
|
+
import { isEscrowEnabled, notifyEscrowHoldEvent } from '../services/escrow-enclave';
|
|
19
|
+
|
|
20
|
+
// Two queues of ten 3-second deliveries leave half the 120-second Lambda
|
|
21
|
+
// budget for database work. Least-recently-attempted ordering prevents starvation.
|
|
22
|
+
const DEFAULT_REMINDER_BATCH_LIMIT = 10;
|
|
23
|
+
|
|
24
|
+
export interface EscrowHoldReminderResult {
|
|
25
|
+
reminded: number;
|
|
26
|
+
expired: number;
|
|
27
|
+
failed: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RunEscrowHoldRemindersOptions {
|
|
31
|
+
now?: Date;
|
|
32
|
+
limit?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Test-only seam; defaults to the real `notifyEscrowHoldEvent`. Lets
|
|
35
|
+
* tests force a deterministic delivery failure without touching
|
|
36
|
+
* Postmark/FCM. Real callers (the Lambda handler) never pass this, so
|
|
37
|
+
* the effective public signature stays `{ now?, limit? }`.
|
|
38
|
+
*/
|
|
39
|
+
notify?: typeof notifyEscrowHoldEvent;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Given a hold's stored `tenantId`, resolves the same `ResolvedTenant` shape
|
|
44
|
+
* route handlers get from `ctx.tenant`. A scheduled job has no request to
|
|
45
|
+
* read headers from, so this reuses `resolveTenantFromRequest`'s third
|
|
46
|
+
* resolution step directly (deploy-level id, same as `DEFAULT_TENANT_ID`)
|
|
47
|
+
* rather than duplicating the id → branding lookup it already does. Holds
|
|
48
|
+
* created before `tenantId` existed fall back to `DEFAULT_TENANT_ID` / the
|
|
49
|
+
* package's own 'learncard' default, exactly like any other unheadered call.
|
|
50
|
+
*/
|
|
51
|
+
const resolveTenantById = (tenantId: string | undefined): ResolvedTenant =>
|
|
52
|
+
resolveTenantFromRequest({}, tenantId);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deliberately drops the caught error's message, not just its stack: a real
|
|
56
|
+
* delivery-channel rejection (Postmark/FCM) can embed the recipient address
|
|
57
|
+
* in its own message text, so forwarding it here would leak PII through the
|
|
58
|
+
* one logging path meant to report the failure. Mirrors
|
|
59
|
+
* `notifications.ts`'s `logDeliveryFailure`, which makes the same trade-off
|
|
60
|
+
* for the same reason. The error's name/constructor is still useful for
|
|
61
|
+
* triage (e.g. "MongoServerError" vs "TypeError") without that risk.
|
|
62
|
+
*/
|
|
63
|
+
const logJobError = (holdId: string, error: unknown): void => {
|
|
64
|
+
console.error('[escrow-hold-reminders] failed to process hold', {
|
|
65
|
+
holdId,
|
|
66
|
+
errorType: error instanceof Error ? error.name : typeof error,
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Hourly maintenance job: expires stale pending holds, then sends a T-24h
|
|
72
|
+
* `reminder` notification to every pending, hold-policy escrow hold whose
|
|
73
|
+
* waiting period ends within 24h and that has never been reminded.
|
|
74
|
+
*
|
|
75
|
+
* Escrow can be entirely disabled (no `ESCROW_ENCLAVE_MODE`) on a given
|
|
76
|
+
* stage/tenant deploy. Rather than a Serverless-level `schedule.enabled`
|
|
77
|
+
* conditional (its value must be a literal boolean, not an interpolated env
|
|
78
|
+
* string, so gating it from `${env:ESCROW_ENCLAVE_MODE}` is fragile), the
|
|
79
|
+
* schedule stays always-enabled in serverless.yml and this job no-ops
|
|
80
|
+
* instead — the same disabled/enabled signal the escrow router itself uses
|
|
81
|
+
* via `getEscrowEnclave()` throwing when `isEscrowEnabled()` is false.
|
|
82
|
+
*/
|
|
83
|
+
export const runEscrowHoldReminders = async (
|
|
84
|
+
options: RunEscrowHoldRemindersOptions = {}
|
|
85
|
+
): Promise<EscrowHoldReminderResult> => {
|
|
86
|
+
const empty: EscrowHoldReminderResult = { reminded: 0, expired: 0, failed: 0 };
|
|
87
|
+
const now = options.now ?? new Date();
|
|
88
|
+
if (!isEscrowEnabled()) {
|
|
89
|
+
// Disabling recovery must not silence outstanding revocation alarms.
|
|
90
|
+
if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
|
|
91
|
+
return empty;
|
|
92
|
+
}
|
|
93
|
+
const limit = Math.max(
|
|
94
|
+
1,
|
|
95
|
+
Math.min(options.limit ?? DEFAULT_REMINDER_BATCH_LIMIT, DEFAULT_REMINDER_BATCH_LIMIT)
|
|
96
|
+
);
|
|
97
|
+
const notify = options.notify ?? notifyEscrowHoldEvent;
|
|
98
|
+
|
|
99
|
+
const expired = await expireStaleEscrowHolds(now);
|
|
100
|
+
const candidates = await findEscrowHoldsDueForReminder(now, limit);
|
|
101
|
+
|
|
102
|
+
let reminded = 0;
|
|
103
|
+
let failed = 0;
|
|
104
|
+
|
|
105
|
+
// Budget at most 30s of enclave calls beside the two 30s delivery queues.
|
|
106
|
+
// Attempts ordering ensures a repeatedly unavailable cancellation cannot starve others.
|
|
107
|
+
const cancelLimit = Math.min(
|
|
108
|
+
limit,
|
|
109
|
+
3,
|
|
110
|
+
Math.max(1, Math.floor(30_000 / environment.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS))
|
|
111
|
+
);
|
|
112
|
+
for (const hold of await findEscrowHoldsPendingEnclaveCancellation(cancelLimit)) {
|
|
113
|
+
try {
|
|
114
|
+
const userKey = hold.enclaveEnvelope
|
|
115
|
+
? undefined
|
|
116
|
+
: await findUserKeyByAuthProvider(hold.authProvider.type, hold.authProvider.id);
|
|
117
|
+
if (!(await cancelHoldInEnclave(hold, userKey, now))) failed += 1;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
failed += 1;
|
|
120
|
+
logJobError(hold._id, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Check the whole backlog, not only this run's bounded retry batch.
|
|
124
|
+
if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
|
|
125
|
+
|
|
126
|
+
// A hold is inserted with this marker before the route attempts delivery,
|
|
127
|
+
// so Lambda freezes/timeouts cannot lose the security-critical start email.
|
|
128
|
+
for (const hold of await findEscrowHoldsPendingStartNotification(limit)) {
|
|
129
|
+
try {
|
|
130
|
+
await recordEscrowStartNotificationAttempt(hold._id, now);
|
|
131
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
132
|
+
hold.authProvider.type,
|
|
133
|
+
hold.authProvider.id
|
|
134
|
+
);
|
|
135
|
+
if (
|
|
136
|
+
userKey &&
|
|
137
|
+
!(await notify({
|
|
138
|
+
kind: 'started',
|
|
139
|
+
hold,
|
|
140
|
+
userKey,
|
|
141
|
+
tenant: resolveTenantById(hold.tenantId),
|
|
142
|
+
}))
|
|
143
|
+
)
|
|
144
|
+
failed += 1;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
failed += 1;
|
|
147
|
+
logJobError(hold._id, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const candidate of candidates) {
|
|
152
|
+
let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
|
|
153
|
+
try {
|
|
154
|
+
// Lease before send; only successful delivery writes the sent marker.
|
|
155
|
+
claimed = await claimEscrowHoldForReminder(candidate._id, now);
|
|
156
|
+
if (!claimed) continue;
|
|
157
|
+
|
|
158
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
159
|
+
claimed.authProvider.type,
|
|
160
|
+
claimed.authProvider.id
|
|
161
|
+
);
|
|
162
|
+
if (!userKey) continue;
|
|
163
|
+
|
|
164
|
+
const tenant = resolveTenantById(claimed.tenantId);
|
|
165
|
+
|
|
166
|
+
if (await notify({ kind: 'reminder', hold: claimed, userKey, tenant })) reminded += 1;
|
|
167
|
+
else failed += 1;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failed += 1;
|
|
170
|
+
logJobError(candidate._id, error);
|
|
171
|
+
} finally {
|
|
172
|
+
if (claimed) await releaseEscrowReminderClaim(claimed);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { reminded, expired, failed };
|
|
177
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Collection } from 'mongodb';
|
|
3
|
+
import mongodb from '@mongo';
|
|
4
|
+
|
|
5
|
+
export const AUTH_SUBJECTS_COLLECTION = 'authsubjects';
|
|
6
|
+
|
|
7
|
+
export interface AuthSubjectAttributes {
|
|
8
|
+
email?: string;
|
|
9
|
+
emailVerified: boolean;
|
|
10
|
+
displayName?: string;
|
|
11
|
+
pictureUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MongoAuthSubjectType extends AuthSubjectAttributes {
|
|
15
|
+
subject: string;
|
|
16
|
+
identityKey: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
lastLoginAt: Date;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const getAuthSubjectsCollection = (): Collection<MongoAuthSubjectType> =>
|
|
22
|
+
mongodb.collection<MongoAuthSubjectType>(AUTH_SUBJECTS_COLLECTION);
|
|
23
|
+
|
|
24
|
+
const MONGO_NAMESPACE_NOT_FOUND = 26;
|
|
25
|
+
const MONGO_INDEX_NOT_FOUND = 27;
|
|
26
|
+
|
|
27
|
+
const mongoErrorCode = (error: unknown): unknown =>
|
|
28
|
+
typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `identityKey` is unique: each sign-in method maps to exactly one subject.
|
|
32
|
+
* `subject` is deliberately NOT unique — linked sign-in methods for the same
|
|
33
|
+
* person (email code + native Google/Apple with the same verified email) share
|
|
34
|
+
* one subject, so Keycloak and the wallet see a single account. Databases
|
|
35
|
+
* created before linking existed carry a unique `subject` index; drop it.
|
|
36
|
+
*/
|
|
37
|
+
export const createAuthSubjectIndexes = async (): Promise<void> => {
|
|
38
|
+
const collection = getAuthSubjectsCollection();
|
|
39
|
+
|
|
40
|
+
const existing = await collection.indexes().catch(error => {
|
|
41
|
+
if (mongoErrorCode(error) === MONGO_NAMESPACE_NOT_FOUND) return [];
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
const legacyUniqueSubject = existing.find(
|
|
45
|
+
index =>
|
|
46
|
+
index.unique === true && index.key?.subject === 1 && Object.keys(index.key).length === 1
|
|
47
|
+
);
|
|
48
|
+
if (legacyUniqueSubject?.name) {
|
|
49
|
+
await collection.dropIndex(legacyUniqueSubject.name).catch(error => {
|
|
50
|
+
if (mongoErrorCode(error) !== MONGO_INDEX_NOT_FOUND) throw error;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await collection.createIndex({ subject: 1 });
|
|
55
|
+
await collection.createIndex({ identityKey: 1 }, { unique: true });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let authSubjectIndexesReady: Promise<void> | undefined;
|
|
59
|
+
|
|
60
|
+
/** Create the indexes once per process (startup in Docker, first login in Lambda). */
|
|
61
|
+
export const ensureAuthSubjectIndexes = (): Promise<void> => {
|
|
62
|
+
if (!authSubjectIndexesReady) {
|
|
63
|
+
authSubjectIndexesReady = createAuthSubjectIndexes().catch(error => {
|
|
64
|
+
authSubjectIndexesReady = undefined;
|
|
65
|
+
console.error('Unable to create AuthSubject indexes:', error);
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return authSubjectIndexesReady;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const MONGO_DUPLICATE_KEY = 11000;
|
|
73
|
+
|
|
74
|
+
const isDuplicateKeyError = (error: unknown): boolean =>
|
|
75
|
+
typeof error === 'object' &&
|
|
76
|
+
error !== null &&
|
|
77
|
+
(error as { code?: unknown }).code === MONGO_DUPLICATE_KEY;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shared upsert for both `getOrCreateAuthSubject` and `getOrCreateAuthSubjectLinkedTo`.
|
|
81
|
+
*
|
|
82
|
+
* Two concurrent first logins for the same identity can both miss the lookup
|
|
83
|
+
* and race on the insert; the unique `identityKey` index rejects the loser
|
|
84
|
+
* with E11000. Retrying once then finds the winner's document and only runs
|
|
85
|
+
* the `$set` half of the upsert. `subjectForInsert` only takes effect when this
|
|
86
|
+
* `identityKey` doesn't exist yet (`$setOnInsert`) — an existing record always
|
|
87
|
+
* keeps its own `subject`, regardless of what's passed here.
|
|
88
|
+
*/
|
|
89
|
+
const upsertAuthSubject = async (
|
|
90
|
+
identityKey: string,
|
|
91
|
+
subjectForInsert: string,
|
|
92
|
+
attrs: AuthSubjectAttributes
|
|
93
|
+
): Promise<MongoAuthSubjectType> => {
|
|
94
|
+
await ensureAuthSubjectIndexes();
|
|
95
|
+
const now = new Date();
|
|
96
|
+
const fields: Partial<MongoAuthSubjectType> = {
|
|
97
|
+
lastLoginAt: now,
|
|
98
|
+
emailVerified: attrs.emailVerified,
|
|
99
|
+
};
|
|
100
|
+
if (attrs.email !== undefined) fields.email = attrs.email;
|
|
101
|
+
if (attrs.displayName !== undefined) fields.displayName = attrs.displayName;
|
|
102
|
+
if (attrs.pictureUrl !== undefined) fields.pictureUrl = attrs.pictureUrl;
|
|
103
|
+
|
|
104
|
+
const upsert = () =>
|
|
105
|
+
getAuthSubjectsCollection().findOneAndUpdate(
|
|
106
|
+
{ identityKey },
|
|
107
|
+
{
|
|
108
|
+
$setOnInsert: { subject: subjectForInsert, identityKey, createdAt: now },
|
|
109
|
+
$set: fields,
|
|
110
|
+
},
|
|
111
|
+
{ upsert: true, returnDocument: 'after' }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
let record: MongoAuthSubjectType | null;
|
|
115
|
+
try {
|
|
116
|
+
record = await upsert();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!isDuplicateKeyError(error)) throw error;
|
|
119
|
+
record = await upsert();
|
|
120
|
+
}
|
|
121
|
+
if (!record) throw new Error('Unable to persist authentication subject');
|
|
122
|
+
return record;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Persist a random, permanent subject independently of Firebase and UserKey. */
|
|
126
|
+
export const getOrCreateAuthSubject = (
|
|
127
|
+
identityKey: string,
|
|
128
|
+
attrs: AuthSubjectAttributes
|
|
129
|
+
): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, randomUUID(), attrs);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Same as `getOrCreateAuthSubject`, but a brand-new `identityKey` adopts the given
|
|
133
|
+
* `subject` instead of minting a random one — used to unify a native social sign-in
|
|
134
|
+
* onto an already-existing email-code identity for the same verified email. If
|
|
135
|
+
* `identityKey` already has its own record (e.g. this provider/sub was seen before),
|
|
136
|
+
* that record's existing `subject` is kept untouched; it is never overwritten.
|
|
137
|
+
*/
|
|
138
|
+
export const getOrCreateAuthSubjectLinkedTo = (
|
|
139
|
+
identityKey: string,
|
|
140
|
+
subject: string,
|
|
141
|
+
attrs: AuthSubjectAttributes
|
|
142
|
+
): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, subject, attrs);
|
|
143
|
+
|
|
144
|
+
/** Read-only lookup used to find an existing identity to link a new one to. */
|
|
145
|
+
export const findAuthSubjectByIdentityKey = (
|
|
146
|
+
identityKey: string
|
|
147
|
+
): Promise<MongoAuthSubjectType | null> => getAuthSubjectsCollection().findOne({ identityKey });
|