@rytass/member-base-nestjs-module
v0.15.0
Published
Rytass Member System NestJS Base Module
Downloads
2,289
Maintainers
Readme
Member Base System for NestJS Projects
Members, passwords, tokens and Casbin authorization for NestJS — plus a pluggable authentication gateway and an optional OpenID Connect provider endpoint.
What you can build with it
| Capability | Entry point | Status |
| --------------------------------------------- | ---------------- | ------------------- |
| Members, password policy, JWT session, Casbin | package root | always on |
| Account/password login | package root | always on |
| Google / Facebook / custom OAuth2 login | package root | configure to enable |
| Login against any OIDC issuer (relying party) | package root | configure to enable |
| Login against an LDAP / Active Directory | /ldap | opt-in subpath |
| Be an OIDC provider for other services | /oidc-provider | opt-in subpath |
| GraphQL DTOs | /graphql | opt-in subpath |
Authentication sources and the issuer endpoint are independent: any source can back the issuer. Jump to Deployment Topologies for complete, copy-pasteable setups of each combination.
authentication sources this application
┌─────────────────────────────┐ ┌──────────────────────────┐
│ account + password (built-in)│──┐ │ AuthenticationGateway │
│ Google / Facebook / OAuth2 │──┤ │ │ │
│ any OIDC issuer │──┼───────▶│ member + Casbin │
│ LDAP / Active Directory │──┤ │ │ │
│ your own provider │──┘ │ ┌────────┴───────────┐ │
└─────────────────────────────┘ │ │ own API (guarded) │ │
│ │ OIDC endpoint │──┼──▶ other services
│ └────────────────────┘ │
└──────────────────────────┘How this document is arranged
It is long because the package covers a lot; you are not meant to read it start to finish.
| If you want to | Read | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Get something running | Installation, Defining Your Member Entity, then the topology that matches you | | Understand permissions | RBAC with Domains and Request-Aware Authorization | | Tell 401 from 403 | How the Guard Reports a Denial | | Seed the first administrator | Default Admin Bootstrap | | Serve GraphQL | GraphQL Support | | Put the session in a cookie | Sessions and Cookies | | Make logout end the session | Login Sessions and Refresh Token Rotation | | Look up an option | Configuration Reference | | Add a login source | Authentication Gateway, LDAP, OIDC issuer | | Sign in with Microsoft Entra | Microsoft Entra ID | | Stop writing callback routes | Mounted Login Routes | | Log in from a native app | Native apps: why a cookie cannot reach them | | Reconcile against a directory | Reading a directory through the gateway | | Become an issuer yourself | Acting as an OpenID Connect Provider | | Upgrade an existing install | CHANGELOG.md — each release carries its own migration notes; from 0.14, Upgrading from 0.14 | | Find what something is called | Type Aliases and Injection Tokens |
Installation
npm install @rytass/member-base-nestjs-moduleRequired peer dependencies:
npm install @nestjs/common @nestjs/core @nestjs/typeorm typeorm argon2 jsonwebtokenOptional peer dependencies — install only the ones you use:
| Package | Needed when |
| ---------------------------- | ----------------------------------------------------------------- |
| typeorm-adapter | You set casbinAdapterOptions (database-backed Casbin policy) |
| @nestjs/graphql, graphql | You import from @rytass/member-base-nestjs-module/graphql |
| ldapts | You import from @rytass/member-base-nestjs-module/ldap |
| oidc-provider | You import from @rytass/member-base-nestjs-module/oidc-provider |
Nothing is pulled in by importing the package root: an entry point you never import is never resolved, so its dependency is never required and its tables are never created. See Subpath isolation.
typeorm-adapter is an optional peer dependency
Install it yourself when you set casbinAdapterOptions, and not otherwise:
npm install typeorm-adapterIt is not bundled because typeorm-adapter declares typeorm as its own dependency
(^0.3.17) rather than a peer dependency. Bundled, any consumer whose typeorm version
fell outside that range got a second copy of TypeORM under
node_modules/typeorm-adapter/node_modules/. getMetadataArgsStorage() is a
module-level singleton, so the two copies split the entity metadata registry and produced
errors such as Entity metadata for X was not found, with nothing pointing back to this
package. The same nesting pulled in reflect-metadata@^0.1.13 alongside the ^0.2.x that
NestJS 11 and TypeORM require, splitting the Reflect polyfill too. Keeping it optional
means your typeorm version is the only one installed.
Setting casbinAdapterOptions without installing it throws at startup with an explicit
message rather than booting into a broken authorization state.
Defining Your Member Entity
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MemberBaseModule } from '@rytass/member-base-nestjs-module';
import { MemberEntity } from './models/member.entity.ts';
@Module({
imports: [
TypeOrmModule.forRoot({
// ... typeorm configuration
}),
MemberBaseModule.forRoot({
memberEntity: MemberEntity, // register custom child entity
}),
],
})
export class AppModule {}
// models/member.entity.ts
import { BaseMemberEntity } from '@rytass/member-base-nestjs-module';
import { ChildEntity, Column, OneToMany, Relation } from 'typeorm';
import { MemberOrderEntity } from './member-order.entity.ts';
@ChildEntity()
export class MemberEntity extends BaseMemberEntity {
@Column({ type: 'boolean', default: 0 })
isVerified: boolean;
@OneToMany(() => MemberOrderEntity, order => order.member)
orders: Relation<MemberOrderEntity[]>;
}
// models/member-order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, Relation, JoinColumn } from 'typeorm';
import { MemberEntity } from './member.entity.ts';
@Entity('member_orders')
export class MemberOrderEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
memberId: string;
@ManyToOne(() => MemberEntity, member => member.orders)
@JoinColumn({ name: 'memberId', referencedColumnName: 'id' })
member: Relation<MemberEntity>;
}
// services/member.service.ts
import { DataSource, Repository } from 'typeorm';
import { Injectable, BadRequestException, Inject } from '@nestjs/common';
import { MemberLoginLogRepo, MemberLoginLogEntity } from '@rytass/member-base-nestjs-module';
import { MemberEntity } from '../models/member.entity.ts';
@Injectable()
export class MemberService {
constructor(
private readonly dataSource: DataSource,
@Inject(MemberLoginLogRepo)
private readonly memberLoginLogRepo: Repository<MemberLoginLogEntity>,
) {}
async getMemberAuditLogs(id: string): Promise<MemberLoginLogEntity[]> {
const qb = this.memberLoginLogRepo.createQueryBuilder('logs');
qb.andWhere('logs.memberId = :id', { id });
const logs = await qb.getMany();
return logs;
}
async getMemberWithOrders(id: string): Promise<MemberEntity> {
const qb = this.dataSource.getRepository(MemberEntity).createQueryBuilder('members');
qb.leftJoinAndSelect('members.orders', 'orders');
qb.andWhere('members.id = :id', { id });
const member = await qb.getOne();
if (!member) {
throw new BadRequestException('Member not found');
}
return member;
}
}RBAC with Domains Configuration
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MemberBaseModule } from '@rytass/member-base-nestjs-module';
@Module({
imports: [
TypeOrmModule.forRoot({
// ... typeorm configuration
}),
MemberBaseModule.forRoot({
casbinAdapterOptions: {
type: 'postgres',
host: 'localhost',
username: 'rytass',
password: 'rytass',
database: 'rytass',
schema: 'members',
},
}),
],
})
export class AppModule {}
// controllers/article.controller.ts
import { Controller, Get, Post } from '@nestjs/common';
import { IsPublic, AllowActions } from '@rytass/member-base-nestjs-module';
@Controller('/articles')
export class ArticleController {
@Get('/')
@IsPublic()
list() {
// allow everyone
}
@Post('/')
@AllowActions([
['article', 'create'], // [Subject, Action] — the domain is not declared here
])
create() {
// Only allowed members
}
}
// services/member.service.ts
import { Injectable } from '@nestjs/common';
import { MemberBaseService, CASBIN_ENFORCER } from '@rytass/member-base-nestjs-module';
import type { Enforcer } from 'casbin';
@Injectable()
export class MemberService {
constructor(
private readonly memberBaseService: MemberBaseService,
@Inject(CASBIN_ENFORCER)
private readonly enforcer: Enforcer,
) {}
// Create member and assign permissions
async onApplicationBootstrap() {
// Set role domain actions
await this.enforcer.addPolicy('article-admin', 'articles', 'article', 'create');
await this.enforcer.addPolicy('article-admin', 'articles', 'article', 'update');
await this.enforcer.addPolicy('article-admin', 'articles', 'article', 'list');
await this.enforcer.addPolicy('article-admin', 'articles', 'article', 'delete');
const member = await this.memberBaseService.register('creator', 'complex-password');
await this.enforcer.addGroupingPolicy(member.id, 'article-admin', 'articles');
}
}You can use MemberBaseService.login to get accessToken and put it in header (Authorization) with Bearer prefix to authorize the request.
How a declared action becomes an enforcement
The model is r = sub, dom, obj, act, and the guard calls enforcer.enforce(memberId, domain, subject, action). Only subject and action come from the decorator; domain is never declared on the route:
| Part | Where it comes from |
| ----- | ------------------------------------------------------------------------ |
| sub | The member id in the access token |
| dom | payload.domain from the token, falling back to DEFAULT_CASBIN_DOMAIN |
| obj | The first element of each AllowActions pair |
| act | The second element |
So the policy addPolicy('article-admin', 'articles', 'article', 'create') above is matched by @AllowActions([['article', 'create']]) when the caller's token carries domain: 'articles' — which login(account, password, { domain: 'articles' }) puts there.
Listing several pairs is an OR: the call is allowed if any one of them passes. To decide the domain per request instead of taking it from the token — when the target depends on GraphQL arguments, say — supply a casbinDomainResolver. To use your own decorator in place of AllowActions, set casbinPermissionDecorator.
With enableGlobalGuard on (the default) and no casbinAdapterOptions, there is no enforcer at all: CASBIN_ENFORCER resolves to null, and the guard then denies every route carrying @AllowActions() with a CasbinEnforcerUnavailableError (403). A route marked only @Authenticated() still passes on a valid token, and @IsPublic() still bypasses the guard entirely. That is the failure direction you want, but it does mean a policy-guarded route is unreachable until the adapter is configured.
Turning enableGlobalGuard off inverts this: the guard returns before any of those checks, so @AllowActions() routes are all allowed rather than all denied. Decorate deliberately if you take that route.
Keeping two applications' policies apart (casbinRuleEntity)
Policies live in one table, casbin_rule. Two applications pointed at the same database therefore share it — and since the usual startup routine is "load the authoritative rules, clear the table, write them back", each one's boot briefly empties the table the other is enforcing against. Convergence at the end is not the same as isolation during.
casbinRuleEntity gives each application its own table. Subclass typeorm-adapter's CasbinRule and name the table:
// casbin-rule.entity.ts
import { Entity } from 'typeorm';
import { CasbinRule } from 'typeorm-adapter';
@Entity('backend_casbin_rule')
export class BackendCasbinRule extends CasbinRule {}
// app.module.ts
MemberBaseModule.forRoot({
casbinAdapterOptions: { type: 'postgres' /* ... */ },
casbinRuleEntity: BackendCasbinRule,
});Left unset, the adapter is constructed exactly as before and keeps using casbin_rule, so this changes nothing for an existing deployment. Setting it on an application that already has policies starts that application from an empty table — it does not migrate the rows.
Three caveats worth stating plainly.
Splitting the table separates the cache, not the permissions. If both applications rebuild their policies from the same upstream tables, both still end up with identical contents, and a permission missing from those upstream tables stays missing in both.
The entity is more than a table name. typeorm-adapter constructs every policy row from it, resolves the repository through it, and — on the branch where it opens the connection itself, where synchronize defaults to on — creates the table from it. So it has to keep the ptype and v0–v5 columns the adapter reads and writes, which is exactly what subclassing CasbinRule guarantees. Columns of your own on top are supported; @CreateDateColumn() and @UpdateDateColumn() are the usual pair.
An existing connection needs the entity registered on it. casbinAdapterOptions also accepts { connection: dataSource }, and typeorm-adapter assembles an entities list only on the other branch, the one where it opens the connection itself. Handed a DataSource it takes that one's entity list as it finds it, while still resolving the repository through your class — so declare the entity on that DataSource yourself, and let its synchronize or a migration create the table. Miss it and boot fails inside loadPolicy() with TypeORM's EntityMetadataNotFoundError, which names the entity but not the option that introduced it.
Request-Aware Authorization (casbinDomainResolver and Decision Tracing)
By default, the built-in permission checker enforces against payload.domain ?? DEFAULT_CASBIN_DOMAIN. For per-resource multi-domain models (e.g. the target domain depends on GraphQL arguments), provide a casbinDomainResolver. The resolver receives the original Nest ExecutionContext (and the underlying request), returns one or more candidate domains, and the default checker allows the call if ANY returned domain passes ANY declared action (the same OR semantics as AllowActions). Returning an empty array denies immediately.
The checker result is normalized into a CasbinAuthorizationDecision and attached to request.casbinDecision, so downstream interceptors / services can audit which domain actually granted access.
// app.module.ts
import { Module } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { MemberBaseModule } from '@rytass/member-base-nestjs-module';
import type { CasbinDomainResolverParams } from '@rytass/member-base-nestjs-module';
@Module({
imports: [
MemberBaseModule.forRoot({
casbinAdapterOptions: {
/* ... */
},
// Resolve target domains from the request instead of the token payload.
casbinDomainResolver: ({ context, payload }: CasbinDomainResolverParams): string[] => {
if (!context) return [];
// GraphQL: read resource ids from resolver args, e.g. query documents(projectId: ID!)
const args = GqlExecutionContext.create(context).getArgs<{ projectId?: string; organizationId?: string }>();
// Multi-layer domain fallback: project first, then its organization, then the tenant.
return [
...(args.projectId ? [`project:${args.projectId}`] : []),
...(args.organizationId ? [`organization:${args.organizationId}`] : []),
...(typeof payload.tenantId === 'string' ? [`tenant:${payload.tenantId}`] : []),
];
},
}),
],
})
export class AppModule {}Reading the decision for auditing (e.g. logging when access was granted through organization inheritance):
// interceptors/authorization-audit.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import type { CasbinAuthorizationDecision } from '@rytass/member-base-nestjs-module';
import { Observable } from 'rxjs';
@Injectable()
export class AuthorizationAuditInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = GqlExecutionContext.create(context).getContext<{
req: { casbinDecision?: CasbinAuthorizationDecision };
}>().req;
const decision = request.casbinDecision;
if (decision?.allowed && decision.matchedDomain?.startsWith('organization:')) {
// Access was granted via an inherited organization-level policy; keep an audit trail.
console.log('organization inheritance access', decision.matchedDomain, decision.matchedAction);
}
return next.handle();
}
}Notes:
casbinDomainResolveronly affects the DEFAULT checker. When a customcasbinPermissionCheckeris provided, the resolver is ignored — the custom checker receivescontext/requestin its params (CasbinPermissionCheckerParams) and decides on its own.- Custom checkers may keep returning
Promise<boolean>(legacy signature, fully backward compatible) or return a richCasbinAuthorizationDecision({ allowed, matchedDomain?, matchedAction?, meta? }) for tracing. - Without
casbinDomainResolver, the default checker behavior is unchanged (payload.domain ?? DEFAULT_CASBIN_DOMAIN).
How the Guard Reports a Denial
CasbinGuard can refuse a call for five unrelated reasons, and it throws a different exception for each. Every class is exported from the package root, so an application distinguishes them with instanceof rather than by reading a message:
| Cause | Exception | Status | Message | code |
| ------------------------------------------------------------ | ------------------------------------- | ------ | ------------------------------------ | ------ |
| No token presented | MissingAccessTokenError | 401 | Access token is missing | 120 |
| Token did not verify (bad signature, expired, malformed) | InvalidAccessTokenError | 401 | Access token is invalid or expired | 121 |
| Authenticated, policy said no | PermissionDeniedError | 403 | Permission denied | 122 |
| Handler carries no permission decorator | RouteMissingPermissionMetadataError | 403 | Route has no permission metadata | 123 |
| @AllowActions() route with CASBIN_ENFORCER set to null | CasbinEnforcerUnavailableError | 403 | Casbin enforcer is not configured | 124 |
The two 401s are the only denials that mean the session is unusable. Everything else means the session is fine and this particular call is not allowed — a distinction worth honouring, because treating a 403 as an expired session logs out a user who was merely reading a page containing one field they lack permission for.
The last two rows are configuration mistakes rather than runtime denials. They stay 403 so the deny direction is unchanged and a route nobody declared does not page whoever watches the 5xx rate, but they are separate classes because they are fixed by editing code, not by granting a policy. The undecorated-handler case is additionally logged once per handler, naming it:
WARN [CasbinGuard] Route ArticleController.archive carries none of @AllowActions(), @Authenticated() or @IsPublic(), so it is denied to everyone including a super admin. Decorate it or remove it.A checker returning a CasbinAuthorizationDecision may set reason, which becomes the message of the 403 — so keep it fit for the caller to read, and put anything internal in meta. The whole decision is attached to the thrown PermissionDeniedError as .decision and to the request as request.casbinDecision, neither of which is serialized into the response:
// filters/authorization.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { PermissionDeniedError } from '@rytass/member-base-nestjs-module';
@Catch(PermissionDeniedError)
export class AuthorizationFilter implements ExceptionFilter {
catch(exception: PermissionDeniedError, host: ArgumentsHost): void {
// exception.decision?.matchedDomain / .matchedAction / .meta
console.warn('denied', exception.decision);
// ... respond as usual
}
}Over GraphQL
This package cannot set a GraphQL error code itself — that would make graphql a hard dependency rather than an optional peer. What it can do is give Apollo an exception carrying a status, which arrives as extensions.originalError.statusCode. Map it once in formatError and every resolver gets the right code:
// app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
formatError: (error: GraphQLFormattedError): GraphQLFormattedError => {
const status = (error.extensions?.originalError as { statusCode?: number } | undefined)?.statusCode;
if (status === 401) return { ...error, extensions: { ...error.extensions, code: 'UNAUTHENTICATED' } };
if (status === 403) return { ...error, extensions: { ...error.extensions, code: 'FORBIDDEN' } };
return error;
},
});A GraphQL response then carries FORBIDDEN on the one field the caller lacks permission for while the rest returns data, which is what lets a page degrade gracefully instead of the client concluding the session ended:
{
"data": { "auditTrail": "...", "memberOptions": null },
"errors": [{ "path": ["memberOptions"], "extensions": { "code": "FORBIDDEN" } }]
}That the denied field be nullable is the condition for it, and it is your schema's decision, not this package's: a denial on a non-null field null-propagates to the root, so data comes back null and there is nothing left to degrade to.
Default Admin Bootstrap
Instead of hand-writing the seeding code above, you can declare a default administrator directly in the module options. On application startup the module will create the account and grant it super-admin (allow-all) permissions.
MemberBaseModule.forRoot({
memberEntity: MyMemberEntity,
casbinAdapterOptions: { type: 'postgres' /* ... */ }, // required to grant permissions
defaultAdminAccount: 'root',
// defaultAdminPassword: 'Sup3rStr0ng', // optional — omit to auto-generate
});Behavior:
- On first startup, the
defaultAdminAccountis created and bound to the well-knownSUPER_ADMIN_ROLEgrouping (inDEFAULT_CASBIN_DOMAIN). The built-in permission checker treats any member holding this role as allow-all — it passes every guarded action regardless of domain, without enumerating policies. - Idempotent: if the account already exists at startup, the module does nothing (safe across restarts).
- Password: if
defaultAdminPasswordis omitted, a policy-compliant random password is generated and written to the log once (at creation). Supplied passwords are never logged; a supplied password that fails the policy aborts startup withPasswordDoesNotMeetPolicyError. - No Casbin configured: if
casbinAdapterOptionsis not set (the enforcer is unavailable), the account is still created but the super-admin grant is skipped with a warning. - Custom checker caveat: the allow-all short-circuit lives in the module's default permission checker. If you provide your own
casbinPermissionChecker, honorSUPER_ADMIN_ROLEyourself if you want the same behavior.
SUPER_ADMIN_ROLE is exported, so you can grant super-admin to other members too:
import { SUPER_ADMIN_ROLE, DEFAULT_CASBIN_DOMAIN, CASBIN_ENFORCER } from '@rytass/member-base-nestjs-module';
await enforcer.addGroupingPolicy(member.id, SUPER_ADMIN_ROLE, DEFAULT_CASBIN_DOMAIN);GraphQL Support
Guards work over GraphQL exactly as they do over HTTP — @AllowActions(), @Authenticated() and @IsPublic() all behave the same, and CasbinGuard reads the underlying request itself. The one thing to remember is fieldResolverEnhancers, without which field resolvers run unguarded:
// app.module.ts
import { Module } from '@nestjs/common';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
import { GraphQLContextTokenResolver } from '@rytass/member-base-nestjs-module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
fieldResolverEnhancers: ['guards'], // Important!! Field resolvers are unguarded without it.
debug: true,
playground: true,
autoTransformHttpErrors: true,
// Optional: publishes the caller's token as context.token for your own resolvers.
context: GraphQLContextTokenResolver,
}),
],
})
export class AppModule {}GraphQLContextTokenResolver is a convenience for your own resolvers, not part of authorization: it puts the raw token on context.token, header first and cookie second. Authorization does not go through it, so omitting it changes nothing about who may call what.
The pre-built export assumes the defaults. Build your own whenever the module is configured differently, so that context.token agrees with what the guard actually accepted:
import { createGraphQLContextTokenResolver } from '@rytass/member-base-nestjs-module';
context: createGraphQLContextTokenResolver({
cookieName: 'sid', // match accessTokenCookieName
cookieMode: true, // match cookieMode
});Both mirror the module's own options. cookieMode: false matters in particular: the guard ignores cookies entirely in that mode, and a resolver that kept reading them would hand your resolvers a token the guard had refused — one left over from before the mode was turned off, for instance.
Sessions and Cookies
By default a caller presents its token in the Authorization header and the module writes no cookies at all. cookieMode: true adds the cookie as a second source.
Reading then happens on every request, header first: Authorization: Bearer wins, and the cookie is consulted only if there is no header. Only the access token cookie is ever read to authenticate a request. The refresh token cookie is written so that a refresh route of your own can pick it up and hand the value to memberBaseService.refreshToken(token); the one place the module reads it back is OidcSsoBridge.clearSession, to end the session it belongs to. There is no refresh endpoint in this package, and no route it provides will accept a refresh token as a session.
Writing is narrower than reading. The module sets cookies only where it completes a login itself, and both cookies are written together so the caller does not need an immediate refresh round trip:
| Where | When | Writes |
| ------------------------------------ | ------------------------------------------------ | ---------------- |
| GET /auth/callbacks/:channel | An OAuth2 provider is configured | access + refresh |
| OidcSsoBridge | /oidc-provider is mounted and the bridge is on | access + refresh |
A login you drive yourself — memberBaseService.login(...) — returns a token pair and writes nothing. Set it with the exported resolveCookieOptions to get the same attributes the module would have used.
Names and attributes come from the cookie options; each Max-Age follows its own token's lifetime.
All of it is configured through the cookie options. Nothing is required — each has a working default:
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MemberBaseModule } from '@rytass/member-base-nestjs-module';
@Module({
imports: [
TypeOrmModule.forRoot({
/* ... */
}),
MemberBaseModule.forRoot({
cookieMode: true,
// All optional. Shown with the values they already default to, except
// the names, which are changed here to avoid colliding with another
// service on the same host.
accessTokenCookieName: 'sid',
refreshTokenCookieName: 'sid_r',
cookiePath: '/',
cookieSameSite: 'lax',
// cookieSecure — omitted: https gets Secure, localhost does not
// cookieDomain — omitted: host-only, so only this host can read it
}),
],
})
export class AppModule {}An OAuth callback on https://app.example.com then answers with:
Set-Cookie: sid=...; Max-Age=900; Path=/; Expires=<GMT date>; HttpOnly; Secure; SameSite=Lax
Set-Cookie: sid_r=...; Max-Age=7776000; Path=/; Expires=<GMT date>; HttpOnly; Secure; SameSite=LaxBoth cookies are written, each with its own token's lifetime as Max-Age. On http://localhost:3000 they are identical but without Secure, so local development works without a separate configuration. (Expires is Express mirroring Max-Age; it is not separately configurable.)
What each default adapts to
| Attribute | Adapts how |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Secure | Set when the request is https, and for every host except localhost, 127.0.0.1 and ::1, where a Secure cookie is never stored over plain http |
| Domain | Not emitted, so the browser scopes the cookie to exactly the host that served the response |
| Path | /, so one cookie serves every route |
| Max-Age | From accessTokenExpiration / refreshTokenExpiration |
| HttpOnly | Always on, not configurable |
Behind a reverse proxy, enable app.set('trust proxy', 1). X-Forwarded-Proto then settles the Secure flag directly, which matters because a proxy that does not forward the original Host — nginx's default when proxy_set_header Host is omitted — otherwise makes an https deployment look like plain localhost. cookieSecure: true forces the flag if neither signal is available.
The OIDC provider module is not subject to any of this: OidcSsoBridge takes Secure from the issuer it was configured with, since that describes the public URL and no proxy can rewrite it. cookieSecure still overrides it.
Sharing a session across subdomains
The default is host-only: a cookie set by idp.example.com is not sent to app.example.com. To share one session across siblings, name the parent explicitly:
MemberBaseModule.forRoot({ cookieMode: true, cookieDomain: '.example.com' });This is opt-in on purpose. Every subdomain under that name can then read the session, including any hosted by someone else, so it should be a deliberate decision rather than a default.
Do not override the DI tokens
Earlier versions of this document suggested overriding ACCESS_TOKEN_COOKIE_NAME / REFRESH_TOKEN_COOKIE_NAME from the application's providers array. That does not work, because Nest resolves providers per module:
CasbinGuardandOAuthCallbacksControllerare declared byMemberBaseModuleitself, so they resolve its own binding directly.OidcSsoBridgeis declared in the OIDC provider module and reaches the same binding throughMemberBaseModule's@Global()export — falling back to the default names if it is absent, since it injects them@Optional().
An application-level provider reaches only the application's own components. The module keeps writing the default names, with no error or warning to indicate it. Use the options above.
Login Sessions and Refresh Token Rotation
Every login opens a row in member_sessions, and every refresh token is bound to one. That row is what a logout ends. Without it a logout can only make one browser forget its token: the token itself stays a validly signed JWT for the rest of its 90 days, a copy of it keeps refreshing, and a refresh response that arrives after the logout signs the user straight back in.
This is always on. There is no switch, and nothing to configure for it to work — the table has to exist, and that is all.
const { accessToken, refreshToken } = await memberBaseService.login(account, password, { ip, userAgent });
// Your refresh route
const pair = await memberBaseService.refreshToken(refreshTokenFromCookieOrBody);
// Your logout route — before you clear the cookies
await memberBaseService.revokeSessionByRefreshToken(refreshTokenFromCookieOrBody);What the tokens carry
| Claim | Access token | Refresh token | Meaning |
| ----- | ------------ | ------------- | ------------------------------------------------------------------- |
| sid | yes | yes | The session — member_sessions.id |
| jti | no | yes | This token's place in the session's rotation — its currentTokenId |
The sid on the access token is there for the application: it is how a "change my password but keep me signed in here" request knows which session "here" is, and how a session list marks the current one. The guard does not look it up. An access token is verified by signature and expiry alone, exactly as before.
Rotation, and what happens to a token used twice
A refresh token works once. refreshToken() hands back a pair whose refresh token has a new jti, and the one presented stops being the session's current token in the same statement.
Presenting a refresh token that has already been rotated away means one of two things — it was copied, or one client kept a stale one — and the server cannot tell which. So the session is revoked (reuse_detected) and both holders have to sign in again. That is the cost of noticing a stolen token at all.
One exception keeps this from firing on ordinary use. Two tabs, or two requests sent together, present the same refresh token a few milliseconds apart. For rotationGraceSeconds (default 10) after a rotation, the token just rotated away is still accepted: it does not rotate again, it is handed a pair for the token the first request already rotated to. "The same pair" here means the same sid and the same current jti — the two responses are not byte-identical, since each is signed at its own instant.
The rotation is one conditional UPDATE … WHERE "currentTokenId" = <the token presented> AND "revokedAt" IS NULL. Two concurrent refreshes both reach it; the database lets exactly one match. The other re-reads the row and is treated as what it has become, a request for the previous token inside the grace window. No row lock is held and no transaction is opened.
Every read of member_sessions goes to the primary, even when TypeORM is configured with read replicas (whose default for a SELECT is a replica). Each decision compares a read against a write made a moment ago, and a replica a second behind would turn an ordinary refresh into a detected reuse. If the loser of a race re-reads and still finds its own token current — the two answers disagree — nothing is decided: it fails with SessionRotationConflictError (below) and the client retries.
Ending a session
| Method | Use |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| revokeSessionByRefreshToken(token, reason = 'logout') | Logout, from the refresh token the client sent. false when there was nothing to end |
| revokeMemberSession(memberId, sessionId, reason = 'logout') | "Sign out that device" for a signed-in member. false if the session is not theirs |
| revokeAllSessions(memberId, { reason, exceptSessionId? }) | "Sign out everywhere", optionally sparing the current device |
| revokeSession(sessionId, reason) | End any session by id. Administrative only — it does not check whose session it is |
| getSessionFromRefreshToken(token) | The session row behind a refresh token, or null |
| isSessionActive(memberId, sessionId) | Whether a session is that member's, still open, and issued under the member's current password — for the sid of an access token |
| reissueSessionTokens(memberId, sessionId, { domain?, authTime? }) | A fresh pair for a session that was kept through a password change (below) |
| memberSessionService.purgeExpiredSessions({ before? }) | Delete expired and revoked rows |
All but the last are on MemberBaseService; MemberSessionService is exported for the rest.
Only the session named is ended. A member signed in on a phone and a laptop who logs out on the phone stays signed in on the laptop.
getSessionFromRefreshToken and revokeSessionByRefreshToken check the token's signature but not its expiry, so a browser that sat closed past the token's lifetime can still end its session. Do not read a non-null answer as "this token can still refresh". Neither throws for a token that is not ours, expired or names no session; both do throw if the database fails, because the session may then still be open.
Use revokeMemberSession, not revokeSession, for anything a member can trigger. A session id that arrives in a request body is just a value someone sent; revokeMemberSession only ends it if it belongs to the member your guard authenticated, so it cannot be used to sign someone else out.
Every id these methods take is checked to be a UUID before it reaches the database. Anything else is treated as a session that does not exist — false, 0, null or SessionNotFoundError — rather than surfacing as a database type error.
isSessionActive also checks the session against the member's password. Each session records the passwordChangedAt its tokens were issued under — the value a refresh token embeds — and isSessionActive requires it to equal the member's current one, by equality rather than by comparing times, so no clock difference between the application and the database matters. A session from before a password change therefore counts as ended even if revoking it failed. The one exception is the session that change was told to keep: changePassword(..., { keepSessionId }) marks it as belonging to the new password, so it stays active without a gap. MemberSessionService.findOpenSession is the lower-level read that checks the row alone; use isSessionActive for anything a token is trusted on.
A password change ends every session of the member:
| Call | Sessions revoked | revokedReason |
| --------------------------------------------------- | ----------------------- | ------------------ |
| changePassword(id, old, new) | all | password_changed |
| changePassword(id, old, new, { keepSessionId }) | all but keepSessionId | password_changed |
| changePasswordWithToken(resetToken, new) | all | password_changed |
| memberBaseAdminService.resetMemberPassword(id, …) | all | admin |
| memberBaseAdminService.archiveMember(id) | all | admin |
If ending the sessions fails once the new password has been written, the change still succeeds and the failure is logged: reporting it as a failed change would have the member retry with a password that is no longer theirs. Nothing is left usable by it — every refresh token issued before the change embeds the old passwordChangedAt and is refused on that alone. archiveMember does it the other way round: it ends the sessions first, and if that fails the member is not archived. It ends them again once the member is archived, for a login that completed in between; a login that was still verifying the password at that moment can still finish afterwards. Nothing can be done with such a session while the member is archived — a refresh is refused because the member is not found, and isSessionActive is false — but restoring the member brings it back, so revoke the member's sessions when you restore one.
Keeping a session through a password change takes one more step. keepSessionId marks that session as belonging to the new password, but the refresh token the device holds still embeds the old passwordChangedAt, and refreshToken() refuses it with PasswordChangedError, as it always has. Give the device a pair that works, in the same request:
const sessionId = accessTokenPayload.sid; // from the request that is changing the password
await memberBaseService.changePassword(memberId, oldPassword, newPassword, { keepSessionId: sessionId });
const pair = await memberBaseService.reissueSessionTokens(memberId, sessionId, { authTime: confirmedAt });
// set the cookies / return the pair in this same responsereissueSessionTokens authenticates nobody, which is why it takes the member as well as the session and refuses (SessionNotFoundError) a session that is not that member's. Pass the member id your guard authenticated — never one read from the request body. For the same reason it does not stamp authTime as "now": the new pair carries no authTime unless you pass one, such as the moment the member confirmed the old password. Every later refresh carries that forward, so leaving it off lasts until the member signs in again: nothing that requires a known authentication time (max_age, a step-up check) will accept this device until then. If the same device happens to be refreshing in another tab at that instant, it fails with SessionRotationConflictError and can simply be retried.
Only a session that the password change kept can be reissued. One from before the change that was not named as keepSessionId is refused with PasswordChangedError, even if it is still open because revoking it failed — otherwise anything holding its access token could turn it into tokens under the new password. So call reissueSessionTokens right after changePassword, as above, rather than exposing it as a route of its own.
If marking the session as kept fails — the write is one more thing the database can refuse — the password change still succeeds, the session is no longer spared and goes with the others, and reissueSessionTokens then fails with a refusal (PasswordChangedError, or SessionRevokedError). Treat that as "the password was changed; sign in again on this device", not as a failed password change.
Keeping a session has one consequence to weigh. Its access token was issued before the change and is valid until it expires; because the session stays active, that token can still do everything an active session's token can for those minutes, including starting an OIDC login where this package is the issuer. The same holds for a refresh that this device already had in flight when the password changed: if it finished its checks just before the change, it still completes, and the access token it returns lives its full lifetime, while the refresh token it returns is refused afterwards. If a password change must cut off every existing token's reach, do not keep a session: change the password and have the member sign in again.
OidcSsoBridge.clearSession(res) — the unified logout of session bridging — now also revokes the session behind the refresh cookie, with or without a cookie parser installed. If the browser sent more than one cookie of that name — a sibling subdomain can plant one — every one of them is revoked, not just the first. It is still synchronous: the cookies are cleared before it returns and the revocation is started without being waited for, a failure being logged. A logout route that must know the session is gone before it answers should await revokeSessionByRefreshToken itself.
Rows are never deleted by the module. A revoked row is what lets a later reuse be recognised as reuse rather than as an unknown session, so how long to keep them is yours to decide — schedule purgeExpiredSessions() if you want them gone, and pass before to keep ended sessions around for audit first.
Telling a refusal from a failure
A client must not sign the user out because a refresh failed. It must sign the user out because the server refused.
| Error | Code | Status | Means |
| -------------------------------- | ---- | ------ | ----------------------------------------------------------------------------------------------- |
| SessionRevokedError | 128 | 400 | Ended on purpose. .reason says how: logout, password_changed, admin, reuse_detected |
| SessionExpiredError | 129 | 400 | The session's lifetime ran out |
| RefreshTokenReuseDetectedError | 130 | 400 | This request is the reuse. The session has just been revoked |
| SessionNotFoundError | 131 | 400 | The token names a session that is not there for it — purged, another member's, or never written |
| PasswordChangedError | 106 | 400 | The password changed after this token was issued |
| MemberNotFoundError | 100 | 400 | The member is gone |
| InvalidToken | 104 | 400 | Not a refresh token of ours, past its own expiry, or issued before sessions existed (no sid) |
| SessionRotationConflictError | 132 | 409 | Not a refusal. Two requests changed the session at once; retry. Not an InvalidToken |
The four session errors share a base class, SessionRejectedError, which is itself an InvalidToken — status 400, instanceof InvalidToken true. That is deliberate: a refresh route written before sessions existed already handles a refused token as InvalidToken, and it keeps working untouched. The code is what tells them apart. Because they are all InvalidTokens, one check covers every refusal of the token itself; the other two rows that are decisions, a changed password and a member who is gone, are separate classes:
import { Errors } from '@rytass/member-base-nestjs-module';
const REFUSALS = [Errors.InvalidToken, Errors.PasswordChangedError, Errors.MemberNotFoundError];
try {
return await memberBaseService.refreshToken(token);
} catch (error) {
if (REFUSALS.some(refusal => error instanceof refusal)) {
clearCookies(res); // refused for good: drop the credential
}
throw error;
}SessionRejectedError is exported too, for code that wants only the four session refusals (128–131). Every row above except the last is a decision, and it will not change on a retry. Anything else is not a decision: SessionRotationConflictError, and whatever the infrastructure throws — a database that is down, a timeout, a dropped connection. refreshToken() lets those through as themselves, where earlier versions reported every non-400 failure as InvalidToken. A client that sees one keeps its credential and tries again.
Retry promptly, within rotationGraceSeconds. In almost every such failure the token was not consumed. The exception is a connection that drops after the rotation was written but before the response arrived: the token was consumed, and the retry is now presenting the one just rotated away. Inside the grace window that is accepted and returns the current pair; after it, it is a reuse and the session is revoked. A client that backs off for longer than the window — common on mobile networks — should either keep its first retry inside it, or have rotationGraceSeconds raised to match.
What a logout does not do
An access token issued before the logout keeps working until it expires — 15 minutes by default (accessTokenExpiration). The guard verifies access tokens without touching the database, and a logout does not change that. This is deliberate: checking the session on every request would put a query in front of every route.
Two consequences worth stating to whoever asks about your security posture:
- After logout, the access token that was already issued is good for up to
accessTokenExpirationmore. Shorten that option to shorten the window. - A refresh that was already on its way when the user logged out can still land, and the pair it carries has a valid access token. It is a dead end: its refresh token belongs to the ended session and is refused. The same holds when the user logs out and signs in as someone else — the first account's late response cannot be extended, and the second account's session is untouched by it.
If a hard cut-off is required, check isSessionActive(payload.id, payload.sid) in a guard of your own on the routes that need it. The module does this itself in one place: an OIDC login started from a member-base session (see Session bridging), because that would otherwise turn a 15-minute access token into a far longer-lived OIDC session.
Options
MemberBaseModule.forRoot({
sessionTracking: {
rotationGraceSeconds: 10,
recordUserAgent: false,
recordIp: false,
// sessionEntity: MySessionEntity,
},
});| Option | Type | Default | Purpose |
| ---------------------- | ------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------- |
| rotationGraceSeconds | number | 10 | How long the token just rotated away is still accepted, 0–300. 0 makes the second concurrent refresh a reuse |
| recordUserAgent | boolean | false | Store the user agent the session was opened from |
| recordIp | boolean | false | Store the IP the session was opened from |
| sessionEntity | new () => MemberSessionEntity | MemberSessionEntity | A subclass to store sessions in, with @Entity('another_table') and any extra columns; see below |
rotationGraceSeconds is refused at startup outside 0–300, or if it is not a finite number. A window much longer than a few seconds stops tolerating concurrent requests and starts accepting a stolen token without anyone noticing.
sessionEntity does not replace the base entity's registration. The module always registers MemberSessionEntity for forFeature, so with autoLoadEntities the member_sessions table is still created, alongside yours, and stays empty. To have only your table, list your entities on the DataSource yourself instead of using autoLoadEntities.
User agent and IP are off by default because they are personal data and nothing in the module reads them. Recording the address never fails a login: an IPv6 zone index (fe80::1%en0) is dropped, and anything that is not an address at all is stored as null. Turned on, they are recorded from whatever the login was given: login(account, password, { ip, userAgent }), gateway.login(channel, credentials, { ip, userAgent }), and the request itself on the mounted redirect routes and the OIDC interaction login. OAuthCallbacksController passes neither.
The session lives as long as its refresh token: expiresAt is set to now + refreshTokenExpiration when the session opens and again at every rotation.
The table
MemberSessionEntity is registered by the module alongside its other entities, so autoLoadEntities picks it up and synchronize creates it. With migrations, add it yourself:
| Column | Type | Null | Notes |
| ------------------- | ------------- | ---- | ----------------------------------------------------------------------- |
| id | uuid | no | Primary key. The sid claim |
| memberId | uuid | no | Indexed. No foreign key is declared by the entity |
| createdAt | timestamp | no | When the login happened |
| lastRefreshedAt | timestamptz | no | Last rotation; equals the login time until the first one |
| expiresAt | timestamptz | no | Indexed. Moves forward at every rotation |
| currentTokenId | uuid | no | The only jti that rotates the session |
| previousTokenId | uuid | yes | The jti rotated away most recently |
| previousRotatedAt | timestamptz | yes | When; the grace window is measured from here |
| revokedAt | timestamptz | yes | Set once, never cleared |
| revokedReason | varchar | yes | logout, reuse_detected, password_changed, admin or expired |
| passwordChangedAt | timestamptz | yes | The member's passwordChangedAt the session's tokens were issued under |
| domain | varchar | yes | The Casbin domain the login was issued for, if any |
| userAgent | varchar | yes | Only with recordUserAgent |
| ip | cidr | yes | Only with recordIp. /32 for IPv4, /128 for IPv6 |
CREATE TABLE "member_sessions" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"memberId" uuid NOT NULL,
"createdAt" timestamp NOT NULL DEFAULT now(),
"lastRefreshedAt" timestamptz NOT NULL,
"expiresAt" timestamptz NOT NULL,
"currentTokenId" uuid NOT NULL,
"previousTokenId" uuid,
"previousRotatedAt" timestamptz,
"revokedAt" timestamptz,
"revokedReason" character varying,
"passwordChangedAt" timestamptz,
"domain" character varying,
"userAgent" character varying,
"ip" cidr,
CONSTRAINT "PK_member_sessions" PRIMARY KEY ("id")
);
CREATE INDEX "IDX_member_sessions_memberId" ON "member_sessions" ("memberId");
CREATE INDEX "IDX_member_sessions_expiresAt" ON "member_sessions" ("expiresAt");
-- Optional. The entity declares no relation, so this is yours to add:
-- ALTER TABLE "member_sessions"
-- ADD CONSTRAINT "FK_member_sessions_memberId"
-- FOREIGN KEY ("memberId") REFERENCES "members" ("id") ON DELETE CASCADE;The module always supplies id itself, so the column default is never used; it is there for rows written by hand. gen_random_uuid() is built into PostgreSQL 13 and later.
If your schema is managed by TypeORM migrations, generate this one with migration:generate rather than pasting the SQL above. TypeORM names primary keys and indexes with hashes of its own (PK_…, IDX_…) and defaults the id to uuid_generate_v4(); a table created by hand under the readable names above shows up as a difference in every later generated migration.
Signing tokens yourself
Use issueTokenPair wherever you issue tokens outside of login() — after a verification step of your own, for instance:
const { accessToken, refreshToken } = await memberBaseService.issueTokenPair(member, { domain, ip, userAgent });It opens the session, waits for the row to be written, and puts the same sid on both tokens. Every login path in this package ends in it.
signRefreshToken(member) and signAccessToken(member) still exist and still work, with two differences to know about:
signRefreshTokenopens a session for the token it signs, but it is synchronous, so it cannot wait for the insert. A refresh in the same process waits for it; a failed insert is logged and shows up only later, asSessionNotFoundErrorat the token's first refresh. With more than one instance of the application, a refresh that reaches another instance before the insert lands — a few milliseconds — also finds no session and is refused.issueTokenPairDetachedand the OIDC bridge'sissueSession, which uses it, share this. Where you can await, useissueTokenPair.signAccessTokencalled on its own carries nosid, because it has no session to name. Pass{ session }— theSessionTokenBinding— to both if you need them to match. Without asid, an access token cannot stand in for an OIDC login through session bridging: if you sign your own tokens and use the bridge, switch toissueTokenPair.
sid and jti are reserved claim names, alongside authTime, domain and, on refresh tokens, passwordChangedAt. Do not return a claim of one of those names from customizedJwtPayload; a store or site id called sid is the usual collision. What happens to one depends on the token: on a refresh token the module's sid and jti always replace yours; on an access token sid is replaced whenever the token is issued for a session (every path in this package), your jti is left as it is, and an access token signed with signAccessToken alone keeps your sid — which is then read as a session id and, naming no session, simply fails every check that needs one.
Upgrading from 0.14
No call has to change. Sessions are always on, and every existing call keeps its signature and its return shape: login, refreshToken, gateway.login, the OAuth and redirect routes, signAccessToken, signRefreshToken, OidcSsoBridge.issueSession and clearSession. A refused refresh is still an InvalidToken with status 400. A client that checks instanceof InvalidToken or the status handles every refusal unchanged. One that matches the code or the message (104, Invalid token) also handles the upgrade itself unchanged; the new refusals afterwards carry their own codes, 128–131, listed above.
Two things happen on their own that you should know about:
- Everyone signs in again, once. A refresh token issued by 0.14 or earlier carries no
sid, so it is refused — with exactly what 0.14 answered any bad refresh token with:InvalidToken, code 104, messageInvalid token, status 400. Whatever your client keys on, it signs the user out as it always did. Access tokens already issued keep working until they expire. - The
member_sessionstable has to exist, like every other table of this package.synchronizecreates it; with migrations, add one (the SQL above). Nothing checks for it at startup, as nothing checks for the package's other tables. Without it every login fails:memberBaseService.login()withPasswordValidationError(500), the real cause being logged;gateway.login(), the OAuth callback and the redirect routes with the database error itself. The two paths that do not wait for the session row —signRefreshTokenand the OIDC bridge'sissueSession— appear to succeed, log the failure, and hand out a refresh token that cannot refresh.
One behaviour to check in your client. refreshToken() has always returned a new refresh token alongside the access token; a client now has to keep using the newest one. A client that ignores it and goes on presenting the token it got at login is, from the server's side, replaying a rotated token: its second refresh revokes the session. If your refresh route sets both cookies, or your client stores both tokens from the response, there is nothing to do.
What is worth adding when you get to it — none of it is required for the upgrade:
- Call
revokeSessionByRefreshTokenin your logout rout
