@okeav/idp-core-dynamodb
v0.1.1
Published
DynamoDB storage adapter for @okeav/idp-core.
Maintainers
Readme
@okeav/idp-core-dynamodb
A DynamoDB storage adapter for @okeav/idp-core — implements all 8 storage repository interfaces (users, sessions, OAuth2 authorization codes/clients/consents, verification tokens, service keys, WebAuthn credentials) using nothing but the official AWS SDK v3 (@aws-sdk/client-dynamodb + @aws-sdk/lib-dynamodb) — no ORM.
Install
npm install @okeav/idp-core-dynamodbUsage
@okeav/idp-core doesn't know this package exists — you wire it in via config.storage.factory, the same seam the Postgres adapter uses:
import { initIdentityProvider } from '@okeav/idp-core';
import { createDynamoDBStorage, ensureTables } from '@okeav/idp-core-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
// Run once, at deploy time — NOT automatically by createDynamoDBStorage().
await ensureTables(new DynamoDBClient({ region: 'us-east-1' }), { tablePrefix: 'Idp' });
await initIdentityProvider({
issuer: 'https://idp.example.com',
storage: {
factory: (resolvedConfig, emailDeps) =>
createDynamoDBStorage({ client: { region: 'us-east-1' }, tablePrefix: 'Idp' }, emailDeps),
},
signingKeys: { keys: { /* ... */ } },
security: { emailHashPepper: '...', tokenHashSecret: '...' },
// ...everything else is identical to the Mongo/Postgres-backed quickstarts.
});config.client is passed straight through to DynamoDBClient's constructor — set region, or endpoint + dummy credentials for local testing against an emulator.
Table provisioning
ensureTables(client, { tablePrefix }) idempotently creates all 10 tables (+ GSIs) this adapter needs — the DynamoDB equivalent of the Postgres adapter's SQL migrations. Not run automatically by createDynamoDBStorage() — call it explicitly at your own deploy step. Unlike SQL, there's no incremental "ALTER" migration story: a table's key schema and GSIs are fixed at creation (adding a GSI later needs UpdateTable and real backfill time — this package doesn't attempt to model that as a "migration file"). createDynamoDBStorage() does a cheap read-only check on startup that the expected tables exist, and throws an actionable error if not; set config.skipTableCheck: true to skip it.
Tables are prefixed (tablePrefix, default Idp) since DynamoDB tables are account+region-global with no schema/database namespacing — lets multiple idp-core deployments share one AWS account.
Schema notes
- Primary keys are application-generated UUIDs (
crypto.randomUUID()). profileandmetadataon the user item are native nested Map attributes — no flattening needed, unlike the Postgres adapter (DynamoDB documents support nested maps directly).mfaRecoveryCodesis a native List-of-Maps attribute directly on the user item (not a separate table) — recovery codes are never queried independently of their user, a textbook DynamoDB "one-to-few, embed it" case.updateById's three patch shapes (profile.*, a fullmfaRecoveryCodesreplace, andmfaRecoveryCodes.<idx>.usedAt) each become a singleUpdateExpression— no transaction needed for any of them.externalProviders(SSO-linked identities) is a separate table ({prefix}UserExternalProviders), sincefindByExternalProvider(provider, providerId)needs an indexed exact-match lookup, which an embedded list can't support efficiently.idp_oauth_clients-equivalent'sclientSecretHashhas no DynamoDB-level "hide by default" either — every repository method strips it unless{ includeSecret: true }is passed. Don't change any query to return the raw item without going through that.- DynamoDB has native TTL, unlike Postgres — but
pruneExpired()on sessions/authorization-codes/verification-tokens is still a real delete here, not a no-op, because AWS's own documentation describes TTL deletion as best-effort/eventually-consistent (up to 48 hours in the worst case) — relying on it alone would leave expired-but-not-yet-swept items readable. TTL is enabled as a storage-cost optimization on top of, not instead of, explicitpruneExpired(). createSessionForLogin(the composite write every login flow uses) runs as a single, genuinely atomicTransactWriteItemscall — no connection-checkout/BEGIN-COMMIT dance needed at all (DynamoDB is HTTP-based/stateless per request), simpler than the Postgres adapter's transaction code.- Every hand-written
UpdateExpression/ConditionExpression/FilterExpressionaliases attribute names defensively (#k0,#k1, ...) rather than relying on a memorized reserved-word list — DynamoDB's reserved words list is long and includes surprisingly ordinary-looking words (name,status,region,counterall tripped this up during development).
Testing
The automated test suite uses dynalite — a pure-JS, in-memory DynamoDB emulator, no Docker/Java needed, starts in milliseconds.
npm testOne documented gap: dynalite does not implement TransactWriteItems at all (confirmed empirically, not just from its own README TODO — it rejects the API outright, not merely failing to roll back correctly). This means SessionRepository.createSessionForLogin — the one method that needs a real multi-item transaction — cannot be exercised through the automated suite at all, not even its happy path. Every other method on every other repository, including every other SessionRepository method, is fully tested. test/smoke-init.test.js proves the rest of the login path (registration, verification, password checking) works correctly through idp-core's real HTTP router, and explicitly asserts that login fails at exactly the TransactWriteItems step, not earlier.
To verify createSessionForLogin's real atomicity (both its happy path and its rollback-on-failure behavior), run _manual-sanity-check.mjs against real AWS DynamoDB or a Java-installed AWS DynamoDB Local:
AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_REGION=us-east-1 node _manual-sanity-check.mjs
# or, against a local Java-based DynamoDB Local:
DYNAMODB_ENDPOINT=http://127.0.0.1:8000 node _manual-sanity-check.mjsIt provisions tables under a timestamped prefix (safe to run against a shared account), creates a user, runs createSessionForLogin successfully, then deliberately forces the transaction to fail (a bogus userId) and asserts all three writes — not just the failing one — were rolled back together. It does not delete the tables it creates; clean those up yourself afterward.
What this package does not do
- No automatic table provisioning or
pruneExpired()scheduling — both are your app's responsibility (same as the Postgres adapter). - No RBAC/authorization decisioning — same as idp-core itself; this package only implements storage.
- No single-table design — this adapter uses one table per repository (10 tables total), prioritizing clarity and a direct mapping to the interface contract over the write/read-capacity optimizations a hand-tuned single-table design could offer at very large scale.
License
MIT © Okeav
