返回 Skill 列表
extension
分类: 开发与工程无需 API Key

data-fair-session

如何在消费会话的服务中(不是登录/账户管理)使用@data-fair/lib会话管理系统。每当任务涉及读取用户身份、检查权限、保护路由、访问账户/组织信息,或在data-fair服务中实现授权逻辑时——无论是在Express/Node后端还是Vue前端组件中——都应使用此技能。当用户提到会话中间件、账户角色、管理员模式或在data-fair上下文中的组织切换时,也应使用它。

person作者: jakexiaohubgithub

data-fair Session Management -- Consumer Guide

This skill covers how services consume sessions produced by Simple Directory (the identity provider). It does NOT cover login flows or account management -- only how a service reads, verifies, and uses session data for authentication and authorization.

Architecture Overview

Sessions are stateless JWT cookies set by Simple Directory. Consuming services never store sessions -- they verify and read them on every request. The JWT is split across two cookies for security: id_token (readable by JavaScript, contains header+payload) and id_token_sign (httpOnly, contains the signature).

Additional cookies carry context: id_token_org (active organization), id_token_dep (active department), id_token_role (switched role), i18n_lang (language).

Key Types

// The central session object -- always present, even for anonymous users
interface SessionState {
  user?: User                          // present if authenticated
  organization?: OrganizationMembership // active org (if user switched to one)
  account?: Account                    // derived: who is currently acting
  accountRole?: string                 // role in the active account
  lang: string                         // always present, defaults to 'fr'
}

// Authenticated variant -- user, account, accountRole guaranteed non-null
type SessionStateAuthenticated = SessionState & Required<Pick<SessionState, 'user' | 'account' | 'accountRole'>>

// The polymorphic "owner" of a resource
interface Account {
  type: 'user' | 'organization'
  id: string
  name: string
  department?: string
  departmentName?: string
}

// Minimal key for matching ownership (used in permission checks)
type AccountKeys = Pick<Account, 'type' | 'id' | 'department'>

The account field is the key abstraction: it normalizes "who is currently acting" regardless of whether it's a personal user or an organization. Resources are owned by an Account; permission checks compare the session's account against the resource's owner.

The user.adminMode flag indicates a platform super-admin in admin mode -- it bypasses all permission checks.

Express Backend

Package: @data-fair/lib-express

Import paths:

import { session } from '@data-fair/lib-express/session.js'
// or the default export:
import session from '@data-fair/lib-express/session.js'

// Sync accessors and helpers (also re-exported from session.js):
import {
  reqSession,
  reqSessionAuthenticated,
  reqAdminMode,
  reqUser,
  reqUserAuthenticated,
  setReqUser,
  setReqSession,
  assertAccountRole,
  assertAdminMode,
  getAccountRole,
  isAuthenticated,
} from '@data-fair/lib-express/session.js'

Initialization (at server startup)

import { session } from '@data-fair/lib-express/session.js'

// Point to Simple Directory's internal URL for JWKS key fetching
session.init(config.privateDirectoryUrl)
// Typically: 'http://simple-directory:8080' in Docker

Middleware (applied to routes)

import { session } from '@data-fair/lib-express/session.js'

// Parse session on all API routes (anonymous access allowed)
app.use('/api', session.middleware())

// Require authentication
app.use('/api/private', session.middleware({ required: true }))

// Require super-admin
app.use('/api/admin', session.middleware({ adminOnly: true }))

The middleware parses cookies, verifies the JWT via JWKS, and caches the result on the request object. It also blocks non-GET requests for pseudoSession tokens (limited API key sessions).

Reading Session in Route Handlers

After middleware has run, use the sync accessors:

app.get('/api/data', (req, res) => {
  const s = reqSession(req)              // SessionState (may be anonymous)
  if (s.user) { /* authenticated */ }
})

app.post('/api/data', (req, res) => {
  const s = reqSessionAuthenticated(req) // throws 401 if not logged in
  // s.user, s.account, s.accountRole are guaranteed present
})

app.delete('/api/admin/thing', (req, res) => {
  const s = reqAdminMode(req)            // throws 401/403 if not super-admin
})

Permission Checking

The permission model is account-based ownership. Resources have an owner: Account field. Check access with:

import { reqSessionAuthenticated, assertAccountRole, assertAdminMode } from '@data-fair/lib-express/session.js'

// Check the user has 'admin' role on the resource's owner account
app.put('/api/resources/:id', async (req, res) => {
  const session = reqSessionAuthenticated(req)
  const resource = await db.findById(req.params.id)
  assertAccountRole(session, resource.owner, 'admin')
  // proceed with update...
})

// Accept multiple roles
assertAccountRole(session, resource.owner, ['admin', 'contrib'])

// Super-admin-only operations
assertAdminMode(session)

// Non-throwing check (returns role string or null)
const role = getAccountRole(session, resource.owner)
if (role === 'admin') { /* can edit */ }

getAccountRole resolution order:

  1. Not authenticated -> null
  2. user.adminMode -> 'admin' (super-admin bypass)
  3. Target is type:'user' matching user.id -> 'admin' (self-ownership)
  4. Match against session.account -> session.accountRole
  5. Otherwise -> null

Options for getAccountRole / assertAccountRole:

  • allAccounts: true -- check all user's org memberships, not just the currently active one
  • acceptDepAsRoot: true -- users in the root org (no department) can access department-scoped resources

Filtering Lists by Ownership

A common pattern for listing resources scoped to the current account:

app.get('/api/resources', async (req, res) => {
  const session = reqSessionAuthenticated(req)
  const query: any = {}

  if (req.query.showAll === 'true') {
    // Only super-admins can see all resources
    assertAdminMode(session)
  } else {
    // Scope to current account
    query['owner.type'] = session.account.type
    query['owner.id'] = session.account.id
    if (session.account.department) {
      query['owner.department'] = session.account.department
    }
  }

  const results = await db.find(query)
  res.json(results)
})

Default Owner on Resource Creation

When creating a resource, default the owner to the session's active account:

app.post('/api/resources', async (req, res) => {
  const session = reqSessionAuthenticated(req)
  const resource = {
    ...req.body,
    owner: req.body.owner ?? session.account
  }
  // Verify user has permission on the specified owner
  assertAccountRole(session, resource.owner, 'admin')
  await db.insert(resource)
})

Ownership Transfer

When changing a resource's owner, check permission on both old and new:

if (patch.owner) {
  assertAccountRole(session, resource.owner, 'admin')  // can remove from old
  assertAccountRole(session, patch.owner, 'admin')     // can assign to new
}

Synthetic Sessions (API Keys, Internal Calls)

Use setReqUser or setReqSession to create a pseudo-session from an API key or service-to-service call, bypassing normal cookie parsing:

import { setReqUser } from '@data-fair/lib-express/session.js'

// Create a session from an API key lookup
app.use(async (req, res, next) => {
  const apiKey = req.headers['x-api-key']
  if (apiKey) {
    const keyRecord = await db.apiKeys.findOne({ key: apiKey })
    setReqUser(req, keyRecord.user, 'fr', keyRecord.account, keyRecord.role)
  }
  next()
})

Passing Session to Service Layer

Thread the session state as a parameter to service functions rather than relying on request context:

// router.ts
const session = reqSessionAuthenticated(req)
await updateResource(session, req.params.id, req.body)

// service.ts
export async function updateResource(
  sessionState: SessionStateAuthenticated,
  id: string,
  body: any
) {
  const resource = await db.findById(id)
  assertAccountRole(sessionState, resource.owner, 'admin')
  // ...
}

Vue Frontend

Package: @data-fair/lib-vue

Import paths:

import { createSession, useSession, useSessionAuthenticated, getAccountRole } from '@data-fair/lib-vue/session.js'
import type { Session, SessionAuthenticated, SiteInfo, Account } from '@data-fair/lib-vue/session.js'

Setup -- Plain Vue SPA

// main.ts
import { createSession } from '@data-fair/lib-vue/session.js'

const session = await createSession({
  // All options are optional, these are the defaults:
  // directoryUrl: '/simple-directory',
  // sitePath: '',
  // defaultLang: 'fr',
})

const i18n = createI18n({ locale: session.state.lang })

createApp(App)
  .use(session)    // provides session via Vue's provide/inject
  .use(i18n)
  .mount('#app')

Setup -- Nuxt 3 SSR

Server-side (Nitro plugin):

// server/plugins/session.ts
import { SessionHandler } from '@data-fair/lib-node/session.js'

export const session = new SessionHandler()

export default defineNitroPlugin(async () => {
  const config = useRuntimeConfig()
  session.initJWKS(config.privateDirectoryUrl)
})

Client-side (Nuxt plugin):

// plugins/session.ts
import { createSession } from '@data-fair/lib-vue/session.js'

export default defineNuxtPlugin(async (app) => {
  app.vueApp.use(await createSession({
    req: app.ssrContext?.event.node.req,  // pass request for SSR cookie reading
    route: useRoute(),
  }))
})

Auto-imports in nuxt.config.ts:

imports: {
  presets: [{
    from: '@data-fair/lib-vue/session.js',
    imports: ['useSession', 'useSessionAuthenticated']
  }]
}

Using Session in Components

useSession() -- returns Session with possibly-undefined user. Use for public-facing pages:

<script setup>
const session = useSession()
</script>

<template>
  <div v-if="session.user.value">
    Logged in as {{ session.user.value.name }}
    <button @click="session.logout()">Logout</button>
  </div>
  <div v-else>
    <button @click="session.login()">Login</button>
  </div>
</template>

useSessionAuthenticated() -- returns SessionAuthenticated where user, account, accountRole are guaranteed. Use for protected pages (throws if not logged in):

<script setup>
const session = useSessionAuthenticated()

// Access current account
const accountType = session.state.account.type  // 'user' | 'organization'
const accountId = session.state.account.id
const role = session.state.accountRole           // 'admin' | 'contrib' | 'user'
</script>

Client-Side Permission Checks

<script setup>
import { getAccountRole } from '@data-fair/lib-vue/session.js'

const session = useSessionAuthenticated()

// Check role for a specific resource owner
const canEdit = computed(() => {
  return getAccountRole(session.state, resource.value.owner) === 'admin'
})

// Super-admin check
const isSuperAdmin = computed(() => !!session.state.user?.adminMode)
</script>

Organization Switching

<script setup>
const session = useSessionAuthenticated()

// List available accounts (personal + organizations)
const accounts = computed(() => {
  const items = [{ label: session.state.user.name, value: null }]
  for (const org of session.state.user.organizations) {
    items.push({
      label: org.department ? `${org.name} / ${org.departmentName}` : org.name,
      value: org.department ? `${org.id}:${org.department}` : org.id
    })
  }
  return items
})

function onSwitch(value: string | null) {
  if (!value) {
    session.switchOrganization(null)
  } else {
    const [org, dep] = value.split(':')
    session.switchOrganization(org, dep)
  }
}
</script>

Session Properties Reference

The Session object returned by useSession():

| Property | Type | Description | |----------|------|-------------| | state | reactive(SessionState) | The raw reactive state object | | user | ComputedRef<User \| undefined> | Current user (null if anonymous) | | organization | ComputedRef | Active organization membership | | account | ComputedRef<Account \| undefined> | Active account (user or org) | | accountRole | ComputedRef<string \| undefined> | Role in active account | | lang | ComputedRef<string> | Current language | | theme | Ref<Theme> | Current theme | | site | Ref<SiteInfo \| null> | Site info (colors, auth mode) |

| Method | Description | |--------|-------------| | login(redirect?) | Navigate to Simple Directory login | | logout(redirect?) | Delete auth cookies and redirect | | switchOrganization(orgId, dep?, role?) | Switch active organization | | switchLang(lang) | Change language (triggers page reload) | | keepalive() | Refresh the JWT token |

Keepalive runs automatically every 10 minutes on non-iframe top windows. Changing account, lang, or dark triggers a full page reload to ensure data consistency.

Common Patterns Summary

  1. Express init: session.init(directoryUrl) at startup
  2. Express middleware: session.middleware() on route groups
  3. Read session: reqSession(req) or reqSessionAuthenticated(req) (sync, after middleware)
  4. Check permission: assertAccountRole(session, resource.owner, 'admin')
  5. Super-admin gate: assertAdminMode(session)
  6. List filtering: scope queries to session.account.{type, id, department}
  7. Default owner: body.owner ?? session.account
  8. Vue setup: createSession({}) as Vue plugin
  9. Vue access: useSession() for public pages, useSessionAuthenticated() for protected pages
  10. Vue permission: getAccountRole(session.state, owner) for conditional UI