@nathapp/nestjs-oauth-prisma
v1.1.1
Published
Prisma adapter for @nathapp/nestjs-oauth
Readme
@nathapp/nestjs-oauth-prisma
Prisma adapter for @nathapp/nestjs-oauth. Binds the core package's repository
tokens to AbstractPrismaRepository-based implementations, plus the default
SIGNING_KEY_PROVIDER.
Install
npm i @nathapp/nestjs-oauth-prisma
npm i -D prisma && npx prisma initSchema
Append the 13 models from the packaged fragment
node_modules/@nathapp/nestjs-oauth-prisma/prisma/oauth.prisma to your app's
schema.prisma. The fragment carries no datasource or generator block — it
is models only. Every DateTime in it uses @db.Timestamptz(6) (PostgreSQL
timestamptz); bare DateTime is not supported — token
expiry comparisons misfire when the database server timezone is not UTC.
PostgreSQL and MySQL. The fragment is authored for PostgreSQL 16 and also
supports MySQL 8.0. For MySQL, assemble it with @db.Timestamptz(6) rewritten
to @db.DateTime(6) (the only change; see the @nathapp/nestjs-iam-prisma
README, "Schema assembly and database support") and keep the MySQL server and
sessions on UTC (time_zone = '+00:00'), because DATETIME(6) stores no
timezone. Upgrading a MySQL database built from an earlier fragment: apply
prisma/sql/long-text-columns.mysql.sql first (PostgreSQL needs nothing); on
MySQL the signing-key PEM columns did not fit before it, so no key could be
created. Each packaged migration has a migration.mysql.sql twin next to its
migration.sql; on MySQL the one-active-signing-key guard is a functional unique
index. Signing keys are per tenant (tenant_id NULL is the global key), so the
guard is one active key per tenant: apply
20261004_oauth_signing_keys_one_active_per_tenant, which replaces the global
index from 20260608_oauth_signing_keys_one_active. On a multi-tenant database
with the old global index only one tenant could ever hold an active key. oauth_scopes.code compares case-insensitively under MySQL's default
collation, so Read and read collide there; keep scope codes lower-case.
prisma/migrations/ in this package holds six incremental patches
(oauth_signing_keys_one_active, oauth_client_advanced_auth_fields,
oauth_resource_indicator, oauth_token_exchange_actor, oauth_access_token_mtls_binding,
oauth_signing_keys_one_active_per_tenant). They are deltas
against a database already built from the fragment, not a complete initial
migration set, and Prisma will not apply them for you — the consumer's migration
history lives in the consumer's own project. Apply them by hand when upgrading
an existing database, after prisma migrate dev has produced your own migration
from the fragment.
Register
import { Module } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg'; // MySQL: PrismaMariaDb from '@prisma/adapter-mariadb' (host/port/user/password/database, no connectionString)
import { PrismaClient } from './generated/prisma/client'; // your generator `output`
import { ClockModule, IdGeneratorModule } from '@nathapp/nestjs-common';
import { PrismaModule } from '@nathapp/nestjs-prisma';
import { OAuthPrismaModule } from '@nathapp/nestjs-oauth-prisma';
@Module({
imports: [
PrismaModule.forRoot({
client: PrismaClient,
clientOptions: { adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }) },
transaction: true,
}),
ClockModule.register(), // provides CLOCK, required by PrismaSigningKeyProvider
IdGeneratorModule.register(), // provides ID_GENERATOR, same requirement
OAuthPrismaModule.register(),
],
})
export class AppModule {}ClockModule.register() and IdGeneratorModule.register() are not optional for
this adapter. PrismaSigningKeyProvider injects CLOCK and ID_GENERATOR
non-optionally, and the { provide: SIGNING_KEY_PROVIDER, useExisting:
PrismaSigningKeyProvider } binding that pulls it in is unconditional — not behind
any option flag. Omit either module and Nest reports Nest can't resolve
dependencies of the PrismaSigningKeyProvider (Symbol(OAUTH_SIGNING_KEY_REPOSITORY),
Symbol(TRANSACTION_MANAGER), ?, Symbol(CLOCK)). Please make sure that the argument
Symbol(ID_GENERATOR) at index [2] is available in the OAuthPrismaModule module.
This applies to the bare-class import form too: imports: [OAuthPrismaModule]
constructs the same provider and needs both modules.
register() takes an optional options object:
| Option | Type | Default | Meaning |
|---|---|---|---|
| global | boolean | true | Register as a global module so the repository tokens resolve anywhere. |
register() was added alongside the bare-class form, not in place of it. There is no
registerAsync.
Both import forms
This adapter is unusual: unlike the other four Prisma adapters, it supports two import forms.
imports: [OAuthPrismaModule] // still supported, non-global
imports: [OAuthPrismaModule.register()] // global by defaultOAuthPrismaModule carries a @Module({ providers, exports }) decorator as well
as the register() factory, so the bare class is a valid import. It was
historically the only form, and existing consumers and tests use it that way.
The bare class is not global: modules that inject these tokens must import it
themselves.
Prefer OAuthPrismaModule.register(). It is global by default, so the OAuth
tokens resolve anywhere without a per-module import, and it leaves room for the
global option. Migrating from the bare class to register() is a one-line
change per module and does not require touching token wiring.
Do not use both forms in one
importsarray.imports: [OAuthPrismaModule, OAuthPrismaModule.register()] // two instancesThe dynamic-module token hash differs from the static class token, so Nest treats them as two separate modules and every repository is instantiated twice. Nothing warns about it. Pick one form per application.
If you already wrap this adapter in @Global()
Pass global: false. Otherwise the adapter's own global exports are injected
in addition to your override, and last-registration-wins silently decides
which implementation is used:
import { Global, Module } from '@nestjs/common';
import { ClockModule, IdGeneratorModule } from '@nathapp/nestjs-common';
import { OAUTH_CLIENT_REPOSITORY } from '@nathapp/nestjs-oauth';
import { OAuthPrismaModule } from '@nathapp/nestjs-oauth-prisma';
import { AuditedOAuthClientRepository } from './audited-oauth-client.repository';
@Global()
@Module({
imports: [
ClockModule.register(), // required by PrismaSigningKeyProvider
IdGeneratorModule.register(), // required by PrismaSigningKeyProvider
OAuthPrismaModule.register({ global: false }),
],
providers: [{ provide: OAUTH_CLIENT_REPOSITORY, useClass: AuditedOAuthClientRepository }],
exports: [OAUTH_CLIENT_REPOSITORY],
})
export class PrismaOverridesModule {}global: false only helps if the adapter is registered once. A second, global
register() elsewhere in the app keeps its global exports, and global: false
here does not remove them — so the collision this section exists to prevent is
still there. Register the adapter in exactly one place.
The global option carries no documentation in the published type definitions
(removeComments: true strips the JSDoc from the emitted .d.ts), so this README
is where it is described. The options object is typed as PrismaAdapterModuleOptions,
which is exported from this package if you need to name the type.
Transactions
Multi-write flows need PrismaModule.forRoot({ client: PrismaClient, transaction: true })
with PrismaClient imported from your generated client (Prisma 7 prisma-client generator).
Every repository resolves its client per operation from the TRANSACTION_MANAGER
token, and the two PrismaModule registration paths fail differently when
transaction is omitted:
PrismaModule.forRoot()bindsTRANSACTION_MANAGERonly whentransaction: trueis set. Without it nothing is bound, the repositories cannot resolve their dependency, and Nest fails at boot.PrismaModule.forRootAsync()always binds the token, but with a manager whoserun()andgetClient()throw and whoseisInTransaction()returnsfalse, so the app boots and fails on first use instead.
Neither path is a silent passthrough: a real transaction manager either joins the transaction already in scope or opens a Prisma interactive transaction.
Both of those are PrismaModule from @nathapp/nestjs-prisma. These adapters
expose only the synchronous register(); they have no registerAsync.
The refresh grant is the case that matters most: it revokes the old refresh token,
issues a replacement, and issues an access token inside a single run(). If that
is not one real transaction, a failure midway leaves the client with no usable
token and a revoked grant.
Tokens bound
This package does not re-export @nathapp/nestjs-oauth. Import the tokens
themselves from @nathapp/nestjs-oauth, and only the implementations and the
module from this package.
| Token | Implementation |
|---|---|
| OAUTH_CLIENT_REPOSITORY | PrismaOAuthClientRepository |
| OAUTH_SCOPE_REPOSITORY | PrismaOAuthScopeRepository |
| OAUTH_AUTH_CODE_REPOSITORY | PrismaOAuthAuthorizationCodeRepository |
| OAUTH_ACCESS_TOKEN_REPOSITORY | PrismaOAuthAccessTokenRepository |
| OAUTH_REFRESH_TOKEN_REPOSITORY | PrismaOAuthRefreshTokenRepository |
| OAUTH_CONSENT_REPOSITORY | PrismaOAuthConsentRepository |
| OAUTH_SIGNING_KEY_REPOSITORY | PrismaOAuthSigningKeyRepository |
| OAUTH_DEVICE_CODE_REPOSITORY | PrismaOAuthDeviceCodeRepository |
| OAUTH_PUSHED_REQUEST_REPOSITORY | PrismaOAuthPushedRequestRepository |
| SIGNING_KEY_PROVIDER | PrismaSigningKeyProvider |
PrismaSigningKeyProvider reads the active OAuthSigningKey row on every call —
it holds no cache. rotate() marks the current key retiring with a 7-day grace
window and mints a new RSA/RS256 keypair in one run(); getVerificationKeys()
publishes the active and retiring keys in the JWKS. An initial key is minted
lazily on first use, so pre-seed a key or call rotate() at deploy to avoid a race
between concurrent first calls. Private keys are stored as PEM: override
encryptPrivateKey() / decryptPrivateKey() for KMS or Vault. The defaults
refuse plaintext storage in production unless
OAUTH_ALLOW_PLAINTEXT_SIGNING_KEYS=true is set.
The module also provides and exports the class
PrismaConsentLogoutTargetResolver, without binding it to anything. To use it as
your LOGOUT_TARGET_RESOLVER, bind the token yourself:
import { Module } from '@nestjs/common';
import { ClockModule, IdGeneratorModule } from '@nathapp/nestjs-common';
import { LOGOUT_TARGET_RESOLVER } from '@nathapp/nestjs-oauth';
import { OAuthPrismaModule, PrismaConsentLogoutTargetResolver } from '@nathapp/nestjs-oauth-prisma';
@Module({
imports: [
ClockModule.register(), // required by PrismaSigningKeyProvider
IdGeneratorModule.register(), // required by PrismaSigningKeyProvider
OAuthPrismaModule.register(),
],
providers: [{ provide: LOGOUT_TARGET_RESOLVER, useExisting: PrismaConsentLogoutTargetResolver }],
})
export class LogoutTargetModule {}Upgrade: mTLS access token bindings
Add OAuthAccessToken.cnfX5tS256 from the schema fragment to your app's schema.
The packaged SQL patch
prisma/migrations/20261003_oauth_access_token_mtls_binding/migration.sql
adds the nullable oauth_access_tokens.cnf_x5t_s256 column. Incorporate this
change into your own migration history, apply it before deploying the updated
adapter, and regenerate your Prisma client.
Earlier adapters discarded the certificate thumbprint when writing access tokens, so existing rows cannot be backfilled reliably. Revoke existing access tokens belonging to mTLS clients and require fresh issuance at cutover. A null column remains valid for clients that do not use certificate binding.
Consent, device-code and pushed-request domain models continue to expose the
public OAuth clientId; the adapter resolves that identifier through the client
relation when writing the internal database foreign key. Access/refresh tokens
and authorization codes continue to use the internal client row id.
