Skip to content

Use case — DynamoDB storage

Use the DynamoDB adapter when the OP's durable and volatile protocol state should live in AWS DynamoDB. It implements every store.Store substore and store.Transactional, so a browser authorization-code flow can run on DynamoDB alone.

Experimental API

op/storeadapter/dynamodb is a published sub-module, but its constructor and options are marked Experimental:. Keep it version-pinned and review its release notes before a minor-version upgrade.

Source: examples/18-dynamodb-store

Install and construct the store

The adapter is a separate module so applications that do not use DynamoDB do not pull the AWS SDK into their dependency graph.

sh
go get github.com/libraz/go-oidc-provider/op/storeadapter/[email protected]

The application owns AWS credential resolution and region selection. Give the configured SDK client to the adapter:

go
import (
  "context"

  awsconfig "github.com/aws/aws-sdk-go-v2/config"
  awsdynamodb "github.com/aws/aws-sdk-go-v2/service/dynamodb"
  oidcdynamo "github.com/libraz/go-oidc-provider/op/storeadapter/dynamodb"
)

ctx := context.Background()
cfg, err := awsconfig.LoadDefaultConfig(ctx)
if err != nil { /* handle configuration error */ }

storage, err := oidcdynamo.New(awsdynamodb.NewFromConfig(cfg))
if err != nil { /* handle construction error */ }

// Pass storage to op.New with the usual issuer, keyset, and login options.

Use oidcdynamo.WithTablePrefix("my_op_") when one AWS account hosts more than one OP. WithNaming overrides individual physical table names and rejects unknown logical names at construction time.

Provision tables deliberately

The adapter creates no tables in New. CreateTables(ctx) is idempotent and intended for development or tests. Production infrastructure should translate storage.TableDefinitions() into CloudFormation, CDK, Terraform, or its own provisioning system; those definitions include each table's key schema, global secondary indexes, and TTL attribute.

go
if err := storage.CreateTables(ctx); err != nil {
  return err // development and test only
}

Each table definition corresponds to a store shape; most substores have one table, while the grant-revocation and revoked-JTI tombstones share one table. The adapter stores the record as JSON plus the key, index, and condition attributes that DynamoDB must query. This keeps record-shape changes out of the table schema.

Refresh-token table upgrades

The current refresh-token definition has by_grant, by_client, and by_parent indexes; it no longer defines a by_handle global secondary index or writes the handle-only index attribute. Refresh-token Find and Consume resolve the presented credential by its primary digest, and chain resolution follows the stored parent relationship. ReconcileIndexes only adds indexes, so an existing table can retain an old by_handle index. It is unused by the current adapter, but remove it manually through the normal DynamoDB infrastructure procedure (for example, UpdateTable) when you want to reclaim its capacity; do not drop and recreate the table for this change. New provisioning and restores must use the current definitions and must not add by_handle back.

Device-code table upgrades

The current device-code table does not use a by_user_code global secondary index. A device record is keyed by the digest of its device_code; when it has a user_code, Save writes a second reservation item under uc#<user_code> in the same TransactWriteItems call. FindByUserCode, ApproveByUserCode, and DenyByUserCode resolve that reservation with a strongly consistent read. The reservation is the uniqueness constraint: a secondary index can find duplicates but cannot prevent two writers from claiming the same code.

Two items, written in one transaction
A single Save writes the device record and a user_code reservation item in one TransactWriteItems call. A lookup by user_code reads the reservation with a strongly consistent read, then resolves the device record from it.Saveone TransactWriteItems — both or neitherpk = digest(device_code)the device record itselfpk = uc#<user_code>the item that makes the code uniqueFindByUserCodea strongly consistent readresolves the record
The reservation item is the only thing enforcing that a short user code is unique. It is written in the same transaction as the record precisely so that a code can never exist without one, or a reservation without a code.

An existing table may therefore still contain the old, unused by_user_code index. It is safe to leave that index in place while planning capacity changes; if it is no longer wanted, remove it through the deployment's normal DynamoDB infrastructure procedure after the pre-upgrade records have expired. Do not drop or recreate the table as part of this upgrade. Device codes written before the reservation-item layout have no uc#... reservation and are intentionally not resolved by user_code; they expire naturally within their normal short lifetime. The adapter does not fall back to the old index, because doing so would let a new request claim a code still held by a live pre-upgrade record.

Expiry and consistency

DynamoDB TTL cleanup is asynchronous. The adapter treats the TTL attribute as storage reclamation only and checks expiry against its clock on every read, so an expired authorization code stays rejected even while DynamoDB has not deleted its item.

Security-sensitive reads use strongly consistent GetItem calls. The transactional adapter buffers writes and commits them through TransactWriteItems; that is what keeps authorization-code issuance, PAR consumption, and their related protocol records atomic.

Authentication-factor stores

The DynamoDB adapter also exposes TOTPs(), Passkeys(), RecoveryCodes(), EmailOTPs(), and AuthnLockouts(). These stores sit outside store.Store; pass them directly to the matching login-flow Step. Their accessor names match the in-memory and SQL adapters, so the login-flow wiring can remain the same when moving the backend.

Run the example locally

The example starts DynamoDB Local, an OP on port 8080, and an RP on port 9090. The emulator remains on the Compose network.

sh
docker compose -f examples/18-dynamodb-store/compose.yaml up -d --build
open http://127.0.0.1:9090/
docker compose -f examples/18-dynamodb-store/compose.yaml down -v

For AWS, do not copy the example's endpoint-override credentials. Let LoadDefaultConfig use the deployment's normal region and credential chain.