@dofe/sso-nestjs
NestJS integration for sso.dofe.ai internal service APIs.
Install
pnpm add @dofe/sso-nestjs @dofe/sso-node @nestjs/axios
Usage
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SsoClientModule } from '@dofe/sso-nestjs';
@Module({
imports: [
SsoClientModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
baseUrl: config.getOrThrow('SSO_INTERNAL_API_URL'),
internalSecret: config.getOrThrow('INTERNAL_API_SECRET'),
serviceName: 'agents.dofe.ai',
}),
}),
],
})
export class AppModule {}
import { Injectable } from '@nestjs/common';
import { SsoClientService } from '@dofe/sso-nestjs';
@Injectable()
export class ExampleService {
constructor(private readonly sso: SsoClientService) {}
async loadUser(userId: string) {
return this.sso.getUser(userId);
}
}
For low-level access, use the underlying @dofe/sso-node client:
await this.sso.client.users.get(userId);
await this.sso.client.teams.getMembers(teamId);
await this.sso.client.permissions.check({ userId, teamId, permission });
For migration from existing local SSO clients, SsoClientService also exposes flat helper methods:
await this.sso.getUser(userId);
await this.sso.getUserTeams(userId);
await this.sso.checkPermission(userId, permission, teamId);
await this.sso.getTeamMembers(teamId);
await this.sso.getUserTeamRole(teamId, userId);
await this.sso.getTenantMembers(tenantId);
await this.sso.getUserTenantRole(tenantId, userId);
For permission checks, prefer SsoPermissionService over hand-written HTTP
clients in consuming projects:
import { Injectable } from '@nestjs/common';
import { SsoPermissionService } from '@dofe/sso-nestjs';
@Injectable()
export class PermissionService {
constructor(private readonly ssoPermissions: SsoPermissionService) {}
checkLoopPermission(userId: string, action: string, teamId?: string) {
return this.ssoPermissions.checkModulePermission(userId, 'vibecoding', 'loops', action, teamId);
}
}
AuthGuard base
Consumer AuthGuards extend DofeSsoAuthGuardBase, delegating the standard
SSO token-verify / blacklist / local-user / admin flow to the SDK and keeping
only project-specific logic in SsoAuthGuardHooks + a subclass.
import { Injectable } from '@nestjs/common';
import {
DofeSsoAuthGuardBase,
type SsoAuthGuardHooks,
type SsoAuthenticatedRequest,
} from '@dofe/sso-nestjs';
@Injectable()
export class MyAuthHooks implements SsoAuthGuardHooks {
async resolveLocalUser(ssoSub, claims) {
/* find/create local user */
}
resolveIsAdmin(localUser, claims) {
return localUser.isAdmin;
}
async isBlacklisted?(token) {
/* optional token blacklist */
}
async shouldBypass?(req, ctx) {
/* optional: health/internal bypass */
}
}
@Injectable()
export class AuthGuard extends DofeSsoAuthGuardBase {
/* project-specific checks only */
}
Dev-only bypass (e.g.
MODE_USER_ID) is intentionally NOT in the base — implement it in your subclass orshouldBypasshook.
OIDC Relying Party
SsoOidcRelyingPartyModule + SsoOidcRelyingPartyService own the PKCE
authorize / callback / exchange / refresh / logout flow. Production must
inject a Redis-backed state store (the default InMemoryStateStore loses
state on restart and does not share across instances):
import {
SsoOidcRelyingPartyModule,
OIDC_RP_STATE_STORE,
createRedisOidcStateStore,
} from '@dofe/sso-nestjs';
SsoOidcRelyingPartyModule.forRootAsync({
inject: [ConfigService, RedisService],
useFactory: (config, redis) => ({
clientId: config.getOrThrow('SSO_CLIENT_ID'),
clientSecret: config.getOrThrow('SSO_CLIENT_SECRET'),
issuerUrl: config.getOrThrow('SSO_ISSUER'),
internalIssuerUrl: config.get('SSO_INTERNAL_API_URL'),
redirectPath: '/auth/oidc/callback',
successPath: '/auth/oidc/success',
serviceName: 'agents.dofe.ai',
}),
stateStore: {
provide: OIDC_RP_STATE_STORE,
inject: [RedisService],
useFactory: (redis) => createRedisOidcStateStore(redis, { keyPrefix: 'dofe:oidc:' }),
},
});
createRedisOidcStateStore is driver-agnostic (duck-typed ioredis surface) —
the SDK does NOT depend on any redis driver; pass your app's existing client.
OIDC helpers (DOFE_RF_COOKIE, classifyRefreshError, resolveOidcUrls,
isSecureCookieRequired, getCookieDomain, getDefaultCookieOptions) are
framework-free and can be imported on their own.
Boundary
This package wraps @dofe/sso-node with a Nest module and HttpService fetcher. It does not include Redis event subscribers, Prisma, migrations, seeds, or database connection logic.
固定浏览器双入口
SessionV2BffOptions.browserContexts 与 resolveBrowserContext(request) 必须同时配置。
第一项 id 为 canonical,Cookie 名与路径必须等于原配置,保留已有 vault;其他
登记 id 使用独立 vault 与交换密文。解析器只能依据边缘覆盖的完整固定元数据组合
选择登记 id,不能接受任意 URL、origin、query 或调用者自报 realm。
配置解析器后,登录使用 establishForRequest(request, reply, tokens, user);
sealExchange(tokens, user, request) 和 redeemExchange(reply, sealed, request)
必须传真实请求。缺请求或未知 id 均拒绝,不能隐式回到 canonical。
publicApiPath 始终为资源合同的 canonical 路径;浏览器 SDK 单独映射 alias 传输路径。
每项 context 的 browserOrigin、cookieName、cookiePath 为固定服务端配置,
__Host- Cookie 必须 Path=/ 且无 Domain。native 跨应用 bootstrap 还需固定
bootstrapRedirectUri(目标 client 精确登记回调);非 canonical 没有该项时拒绝
bootstrap。该 URI 由 BFF 送往 SSO,不接受浏览器 body 中的目标声明。
撤销 SID 的共享索引覆盖全部 context。内部 dispatch proof 保存并校验已选 context, 不根据缺少外部头的 loopback 请求重新选择入口。
Optional session timing
SessionV2ClientOptions.onTiming and SessionV2BffOptions.onTiming accept
SessionV2TimingObserver, (event: SessionV2TimingEvent) => void | Promise<void>.
All timing types are exported from the package root. The hook is optional;
synchronous throws and asynchronous rejections are ignored, and promises are never
awaited. Keep observer synchronous work short.
Events contain only phase, operation, nonnegative durationMs, outcome
(success or error), and optional numeric HTTP status. No identifiers,
request objects, URLs, credentials, or response payloads are emitted.
| Phase | Operation | Boundary |
|---|---|---|
remote | exchange-code, rotate, introspect, renew-ticket, confirmation, bootstrap-exchange, logout-confirmation, logout, call | SSO fetch through JSON body parsing and HTTP error handling. call is the fixed fallback for other paths. Method-level schema checks after the call are outside this span. |
active-entry | read | Vault read, expiry and revocation checks. |
admission | begin-dispatch, begin-window-dispatch | Vault admission, including its remote introspection and lease acquisition. |
completion | complete | Vault completion commit. |
compat-dispatch | dispatch | Compatibility upstream fetch through buffered body and completion, or stream handoff; excludes subsequent streaming duration. |
Nested spans overlap: do not sum admission/completion and their remote spans.
status is the original HTTP response status, even when the SDK normalizes an
error to 503. A remote success describes HTTP/JSON completion, not an active
session grant. Compatibility HTTP statuses >= 400 produce error outcomes without
changing the upstream response. Direct transport remains in-process and emits no
compat-dispatch span. Compatibility response forwarding preserves Server-Timing
and the existing x-request-id; it does not inject or rewrite proof-bound request
headers or forward arbitrary upstream headers. No correlation identifier is added
to events; applications may associate events using their own async context.