Reusable auth/security capability for API and app clients.
api: shared auth types and input validation helpers.app: typed client for auth endpoints.
Use shared helpers/types in your API controllers/services where useful:
sanitizeEmailisValidEmailisStrongPasswordAuthResponse,RegisterResponse,SafeUsernotifyAdminsOnEmailVerifiednotifyUserOnAdminApproval
Import the Nest surface from @scryan7371/sdr-security/nest.
import { Module } from "@nestjs/common";
import {
SecurityDrizzleModule,
SecurityWorkflowsModule,
SECURITY_WORKFLOW_NOTIFIER,
} from "@scryan7371/sdr-security/nest";
import { EmailService } from "./notifications/email.service";
@Module({
imports: [
SecurityDrizzleModule.forRoot(),
SecurityWorkflowsModule.forRoot({
notifierProvider: {
provide: SECURITY_WORKFLOW_NOTIFIER,
useFactory: (emailService: EmailService) => ({
sendAdminsUserEmailVerified: ({ adminEmails, user }) =>
emailService.sendEmailVerifiedNotificationToAdmins(
adminEmails,
user,
),
sendUserAccountApproved: ({ email }) =>
emailService.sendAccountApproved(email),
}),
inject: [EmailService],
},
}),
],
})
export class AppModule {}Consuming apps keep ownership of the shared users table contract. sdr-security
stores security/auth state in its own tables and links them by user id.
- App-owned table:
users(at minimum: UUID primary keyid; the published runtime schema also exposesemailfor the bundled auth services)
sdr-securitytables:security_users(password hash, verified/approved/active flags)security_roles,security_user_rolesrefresh_tokenspassword_reset_tokens
Link key:
- Security table
user_idcolumns referenceusers.id.
This lets each app evolve its user schema independently while reusing the same security workflows, guards, controllers, and migrations.
Typical app query pattern is a join when you need security state:
SELECT u.id, u.email, su.active, su.admin_approved_at, su.email_verified_at
FROM users u
LEFT JOIN security_users su ON su.user_id = u.id
WHERE u.id = $1;Drizzle equivalent:
import { eq } from "drizzle-orm";
import { securityUser, user } from "@scryan7371/sdr-security/drizzle";
const [row] = await db
.select({
id: user.id,
email: user.email,
active: securityUser.active,
adminApprovedAt: securityUser.adminApprovedAt,
emailVerifiedAt: securityUser.emailVerifiedAt,
})
.from(user)
.leftJoin(securityUser, eq(securityUser.userId, user.id))
.where(eq(user.id, userId))
.limit(1);Optional Swagger setup in consuming app:
import { setupSecuritySwagger } from "@scryan7371/sdr-security/nest";
setupSecuritySwagger(app); // default path: /docs/securityRoutes exposed by the shared controller:
POST /security/auth/registerPOST /security/auth/loginPOST /security/auth/forgot-passwordPOST /security/auth/reset-passwordGET /security/auth/verify-email?token=...POST /security/auth/change-password(JWT required)POST /security/auth/logout(JWT required)POST /security/auth/refreshGET /security/auth/me/roles(JWT required)POST /security/workflows/users/:id/email-verified- marks
email_verified_atand notifies admins.
- marks
PATCH /security/workflows/users/:id/admin-approvalwith{ approved: boolean }- updates
admin_approved_atand notifies user when approved (admin JWT required).
- updates
PATCH /security/workflows/users/:id/activewith{ active: boolean }(admin JWT required)GET /security/workflows/roles(admin JWT required)POST /security/workflows/roles(admin JWT required)DELETE /security/workflows/roles/:role(admin JWT required)GET /security/workflows/users/:id/roles(admin JWT required)PUT /security/workflows/users/:id/roles(admin JWT required)POST /security/workflows/users/:id/roleswith{ role: string }(admin JWT required)DELETE /security/workflows/users/:id/roles/:role(admin JWT required)
Use these helpers to standardize notification behavior across apps while still keeping app-specific email sending in your own services.
import { api as sdrSecurity } from "@scryan7371/sdr-security";
await sdrSecurity.notifyAdminsOnEmailVerified({
user: {
id: user.id,
email: user.email,
},
listAdminEmails: () => usersService.listAdminEmails(),
notifyAdmins: ({ adminEmails, user }) =>
emailService.sendEmailVerifiedNotificationToAdmins(adminEmails, user),
});
await sdrSecurity.notifyUserOnAdminApproval({
approved: body.approved,
user: {
email: user.email,
},
notifyUser: ({ email }) => emailService.sendAccountApproved(email),
});Create one client per app session and reuse it across screens:
import { app as sdrSecurity } from "@scryan7371/sdr-security";
const securityClient = sdrSecurity.createSecurityClient({
baseUrl,
getAccessToken: () => accessToken,
});Methods:
registerloginloginWithGooglerefreshrevokelogoutrequestEmailVerificationverifyEmailrequestPhoneVerificationverifyPhone
Install a pinned version:
npm install @scryan7371/sdr-security@0.1.12Shared Drizzle schema definitions live in src/drizzle/schema.ts and are
published from @scryan7371/sdr-security/drizzle.
For local schema and migration development, set DB_HOST, DB_PORT,
DB_USER, DB_PASSWORD, and DB_NAME, then use:
npm run db:generate
npm run db:migrate
npm run db:push
npm run db:studio
npm run db:checkGenerated SQL migrations and Drizzle metadata are written to drizzle/.
The consuming application must create its standardized users table before
running the security migration. The required contract is a public users
table with a UUID primary key named id; the application remains the owner of
that table.
Run the packaged security migrations with an existing node-postgres Drizzle database:
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { migrateSecurityDatabase } from "@scryan7371/sdr-security/drizzle";
// The app migration set must create public.users before security migrations run.
await migrate(db, {
migrationsFolder: "./drizzle",
migrationsTable: "__drizzle_migrations",
});
await migrateSecurityDatabase(db);The helper uses its own __sdr_security_migrations journal so it can safely
run alongside migrations owned by the consuming application. It creates only
the security-owned tables and foreign keys back to users.id. Run both calls
at application startup or in the consuming application's deployment migration
command; do not copy the package's generated SQL into the application.
Register the shared Drizzle provider once in the consuming app. Import it before any security feature modules:
import { Module } from "@nestjs/common";
import { SecurityDrizzleModule } from "@scryan7371/sdr-security/nest";
@Module({
imports: [SecurityDrizzleModule.forRoot()],
})
export class AppModule {}By default the connection uses DB_HOST, DB_PORT, DB_USER, DB_PASSWORD,
and DB_NAME. The same values can be passed directly to forRoot when needed.
Inject the typed database into a service:
import { Inject, Injectable } from "@nestjs/common";
import {
SECURITY_DRIZZLE_DB,
SecurityDatabase,
} from "@scryan7371/sdr-security/nest";
@Injectable()
export class UsersService {
constructor(
@Inject(SECURITY_DRIZZLE_DB)
private readonly db: SecurityDatabase,
) {}
}GitHub Actions publishes the package when a tag matching
sdr-security-v* is pushed. The repository must have an NPM_TOKEN Actions
secret with permission to publish this package.
Start from a clean main branch, then use one of the release scripts:
npm run release:patch
npm run release:minor
npm run release:majorThe script:
- Verifies clean git working tree
- Runs
npm test - Runs
npm run build - Bumps
package.json+package-lock.json - Commits as
chore(release): vX.Y.Z - Tags as
sdr-security-vX.Y.Z - Pushes commit and tag
This tag format triggers .github/workflows/publish.yml.
Do not create a plain version tag such as 0.1.12; it does not match the
publish workflow trigger. If package.json has already been bumped and the
release commit already exists, tag that commit without bumping the version
again:
VERSION=$(node -p "require('./package.json').version")
git tag "sdr-security-v${VERSION}"
git push origin "sdr-security-v${VERSION}"Confirm the publish under GitHub Actions → Publish package, then verify the published version:
npm view @scryan7371/sdr-security versionAn npm E404 Not Found error during the publish PUT can mean that the
workflow's npm token is expired, revoked, or missing publish permission.
- Sign in to npmjs.com as
scryan7371. - Open Profile → Access Tokens → Generate New Token.
- Create a Granular Access Token with:
- Packages and scopes: Read and write
- Package selection: All packages, or the
@scryan7371scope - Bypass two-factor authentication: Enabled
- IP restrictions: None
- Expiration: Choose the longest duration allowed by npm and schedule a reminder to renew it before that date.
- Copy the token when npm displays it. Do not commit it or add it to a local configuration file.
- In the GitHub
sdr-securityrepository, open Settings → Secrets and variables → Actions. - Update the
NPM_TOKENrepository secret with the new token. Paste only the token, without quotes or surrounding whitespace. - Open Actions → Publish package, select the failed run, and choose Re-run jobs → Re-run failed jobs.
If the failed version has not been published, do not bump the package version or create another tag. Re-run the existing tagged workflow after replacing the secret.
SecurityAuthModule.forRoot({auth: {..., social: {allowedReturnUrls, google?, apple?}}}) enables server-mediated authorization-code sign-in. google takes clientId, clientSecret, and callbackUrl; apple takes clientId (Services ID), teamId, keyId, privateKey, and callbackUrl. Use exact allowlisted application return URLs. Keep all secrets on the API. Providers are disabled when omitted.
Endpoints under /security/auth/social: GET providers, POST start (provider, returnUrl, S256 codeChallenge), GET google/callback, POST apple/callback (URL-encoded), POST finish (ticket, codeVerifier), and authenticated GET method. Finish returns authenticated with auth, or pending_approval. The browser-safe client exposes these calls as socialProviders, startSocialSignIn, finishSocialSignIn, and signInMethod.
The caller must generate and keep its verifier locally before starting, verify the returned callback destination, and exchange the single-use ticket within 60 seconds. Authorization state expires after 10 minutes. App JWTs never appear in redirect URLs. The Google flow uses provider PKCE; both providers use state and nonce validation with fixed issuer/JWKS endpoints and RS256 signature verification.
Each account has exactly one method chosen at signup. Existing rows migrate to password; provider subject identifiers are stable identities, not email-based account linking. Existing-email social signup is rejected. Password login/change/reset cannot add password access to provider accounts. Social signup verifies email but preserves activation and admin-approval requirements. No method-switching or linking endpoint exists.
Run migrations before upgrading the API. New security-owned tables cascade identities when app users are deleted. Run npm run test:db with .env.test pointing to a local *_test database, or set SECURITY_TEST_ENV_FILE to an existing test env file. Database tests use a temporary schema and real PostgreSQL; provider credentials and interactive consent are still needed to verify live Google/Apple login.