@elysiumjs/themis

中文文档
0.4.4发布于 2026/11/12Apache-2.0TypeScriptES Module近 30 天下载 3135

Authentication for the Elysium.js framework: passwords, tokens, sessions, guards, CSRF, TOTP, OAuth and passkeys.

此软件包已被标记为弃用

has moved into @elysiumjs/olympus: import from @elysiumjs/olympus/themis; passwords, JWT and signed URLs from @elysiumjs/olympus/harpocrates.

原始使用文档
文档目录11 节
  1. Installation
  2. Setup
  3. Auth
  4. Sessions
  5. CSRF
  6. Passwords
  7. Tokens
  8. Secrets read when they are used
  9. Two-factor codes
  10. OAuth and OpenID Connect
  11. Passkeys

@elysiumjs/themis

Authentication for Elysium.js apps: passwords, tokens, sessions, guards, CSRF, TOTP, OAuth/OpenID Connect and passkeys. It runs on Bun, Node.js and Cloudflare Workers.

Installation

bun add @elysiumjs/themis
# Sessions in Redis or Workers KV
bun add @elysiumjs/themis-redis   # or @elysiumjs/themis-workers

Setup

import { Application, Middleware } from '@elysiumjs/core';
import { Redis } from '@elysiumjs/redis';
import {
    apiKeyGuard,
    CsrfMiddleware,
    jwtGuard,
    sessionGuard,
    SessionMiddleware,
    ThemisModule
} from '@elysiumjs/themis';
import { RedisSessionStore } from '@elysiumjs/themis-redis';

@Application.register({
    modules: [
        ThemisModule.with({
            secret: env.APP_SECRET,
            session: {
                store: new RedisSessionStore({ connection: () => Redis.connection() }),
                ttl: 60 * 60 * 24 * 7
            },
            guards: {
                session: sessionGuard({ user: (id) => users.find(id) }),
                jwt: jwtGuard({ secret: env.JWT_SECRET, user: (claims) => users.find(claims.sub) }),
                apiKey: apiKeyGuard({ find: (hash) => keys.ownerOf(hash) })
            }
        }),
        AppModule
    ]
})
@Middleware.register(SessionMiddleware, CsrfMiddleware)
class App extends Application {}

secret signs the session cookie, encrypts cookie sessions and binds CSRF tokens. Keep it in an environment secret. A secret under 32 bytes is refused at boot (WeakSecretError), and so are shorter JWT and signed URL secrets.

Auth

import type { AuthUser } from '@elysiumjs/themis';

import { Http } from '@elysiumjs/core';
import { Auth } from '@elysiumjs/themis';

declare module '@elysiumjs/themis' {
    interface AuthUser {
        id: string;
    }
}

@Http.controller('/me')
@Auth()
class MeController {
    @Http.get()
    public me(@Auth.user() user: AuthUser) {
        return { id: user.id };
    }

    @Http.get('/tokens')
    @Auth({ guards: ['jwt'] })
    public tokens() {
        /* ... */
    }
}
  • @Auth() tries the configured guards in order, and @Auth({ guards }) only the named ones. A guard is an object that implements Guard, or a guard class, which is built once through the service container so that its constructor gets services injected. It registers AuthMiddleware, so @Middleware.register(AuthMiddleware.guards(['jwt'])) does the same as @Auth({ guards: ['jwt'] }).
  • A request no guard accepts answers a 401 problem. @Auth({ optional: true }) lets it through, and Auth.check() tells whether a user is signed in.
  • The user is available through @Auth.user() on a handler parameter, Auth.currentUser() and ctx.currentUser, so cerberus can read it with getSubject: (ctx) => ctx.currentUser. Declare its type once by augmenting the AuthUser interface. Behind @Auth({ optional: true }), type the parameter AuthUser | undefined.
  • Auth.login(id) signs a user in on the session (and regenerates it), and Auth.logout() signs them out.

Sessions

import { Session } from '@elysiumjs/themis';

const session = Session.current();
session.set('cart', items);
session.flash('notice', 'Saved');
session.regenerate();
  • The stores are MemorySessionStore, RedisSessionStore of @elysiumjs/themis-redis and KvSessionStore of @elysiumjs/themis-workers (a Workers KV binding). Without a store, sessions live in the memory of the process, and themis warns about it outside tests.
  • store: 'cookie' keeps the whole session in an AES-GCM encrypted cookie. It must stay under 4000 characters (SessionTooLargeError), and a copied cookie stays valid until it expires, so keep revocable sessions in a store.
  • The session cookie is signed, HttpOnly, SameSite=Lax and Secure by default.
  • The session is saved after the handler runs, and also when the request ends with a client error (4xx), such as a sign-in followed by a thrown problem.

CSRF

CsrfMiddleware protects cookie sessions with a synchronizer token kept in the session. Unsafe methods need the x-csrf-token header or the _csrf field to match Csrf.token(), or they answer a 403 problem.

Passwords

import { Password } from '@elysiumjs/themis';

const hash = await Password.hash(input);
if ((await Password.verify(input, hash)) && Password.needsRehash(hash)) {
    await users.updateHash(id, await Password.hash(input));
}

The hasher is argon2id (BunPasswordHasher) on Bun and PBKDF2-SHA256 (Pbkdf2PasswordHasher) elsewhere, with 600,000 iterations (100,000 on Cloudflare Workers, which cap PBKDF2 there). ScryptPasswordHasher, from @elysiumjs/themis/scrypt, uses node:crypto. Pick one with Password.use(hasher). Hashes describe their format, so verify reads all three.

Tokens

import { ApiKeys, Jwt, SignedUrl } from '@elysiumjs/themis';

const token = await Jwt.sign({ role: 'admin' }, { secret, subject: user.id, expiresIn: 3600 });
const claims = await Jwt.verify(token, { secret });

const { key, hash } = await ApiKeys.generate({ prefix: 'sk' }); // store the hash, show the key once
await ApiKeys.verify(key, hash);

const link = await SignedUrl.sign('https://app.example.com/download/1', { secret, expiresIn: 600 });
await SignedUrl.verify(link, { secret });

Jwt signs with a secret (HS256) or with an asymmetric key and its algorithm. An invalid or expired token is a 401 problem, and an invalid or expired link a 403 problem.

Jwt.verify refuses a token without an expiry; pass requiredClaims to change the claims a token must carry ([] accepts one without exp). currentDate checks the expiry against another time, and issuedAt signs as of another time.

Secrets read when they are used

Every secret (the ThemisModule secret, JWT secrets and signed URL secrets) can be a function, sync or async, called each time the secret is needed. Read it from a store bound to the request, such as a secrets binding on Cloudflare Workers, and a rotated secret is picked up without a restart. The 32-byte minimum is checked on each read.

ThemisModule.with({ secret: () => secrets.get('app-secret') });

During a rotation, verify with the current key and then the previous one:

const claims = await Jwt.verify(token, {
    keys: [
        { secret: () => secrets.get('jwt-current') },
        { secret: () => secrets.get('jwt-previous') }
    ]
});

The keys are tried in order until one verifies the signature; an expired token or a wrong claim is refused at once.

Two-factor codes

import { BackupCodes, Totp } from '@elysiumjs/themis';

const secret = Totp.generateSecret();
const qr = Totp.uri({ secret, issuer: 'Acme', account: user.email });
const counter = await Totp.verify(code, secret, { window: 1 }); // null when wrong

const { codes, hashes } = await BackupCodes.generate(10);
const { valid, remaining } = await BackupCodes.consume(typed, hashes);

Store the counter Totp.verify returns and refuse a code whose counter is not greater, so that a code works once.

Plain hashes let anyone with a copy of the table test guesses offline. Pass a key to hash the codes with HMAC instead, and the same key to use them:

const { codes, hashes } = await BackupCodes.generate(8, {
    secret: () => secrets.get('backup-codes')
});
const { valid, used } = await BackupCodes.consume(typed, hashes, {
    secret: () => secrets.get('backup-codes')
});

used is the hash the code matched, to mark that one used.

OAuth and OpenID Connect

import { OAuthClient } from '@elysiumjs/themis';

const google = new OAuthClient({
    issuer: 'https://accounts.google.com',
    clientId: env.GOOGLE_CLIENT_ID,
    clientSecret: env.GOOGLE_CLIENT_SECRET,
    redirectUri: 'https://api.example.com/auth/google/callback'
});

// GET /auth/google
const url = await google.authorize();

// GET /auth/google/callback
const { tokens, claims, userInfo } = await google.callback(request.url);

The client uses the authorization code flow with PKCE. The state, the verifier and the OpenID Connect nonce are kept in the session. A provider without discovery takes authorizationServer with its endpoints. A callback with a wrong state or a failed exchange answers a 400 problem. Its detail names the OAuth error code the provider answered; any other failure is logged and answered with a fixed detail.

Passkeys

import { Passkeys } from '@elysiumjs/themis';

const passkeys = new Passkeys({
    rpName: 'Acme',
    rpID: 'example.com',
    origin: 'https://example.com',
    repository: new PasskeyTable()
});

const options = await passkeys.registrationOptions({ id: user.id, name: user.email });
const record = await passkeys.verifyRegistration(response);

const challenge = await passkeys.authenticationOptions();
const used = await passkeys.verifyAuthentication(assertion); // used.userId signs the user in

The challenge is kept in the session. The app stores credentials by implementing PasskeyRepository. A response that does not verify answers a 400 problem. updateCounter gets the previous counter too: answer false when the stored one is no longer it, and the sign-in that lost the race is refused.

An app that keeps its own state between the two calls, such as a multi-step flow, or a client without a session cookie, sets challenges: 'caller' and passes the challenge back:

const passkeys = new Passkeys({ ...options, challenges: 'caller' });

const request = await passkeys.authenticationOptions({ id: user.id });
// keep request.challenge with the flow, then:
const used = await passkeys.verifyAuthentication(assertion, { challenge, userId: user.id });

residentKey: 'required' makes every new passkey discoverable, for a sign-in without a username, and timeout (in milliseconds) is how long the client has to answer. An assertion whose user handle names another user than the passkey's is refused. With a userId, a passkey of another user is refused like an unknown one. A registration needs the userId it was for.

版本与发布记录

@elysiumjs/themis 共发布 17 个版本,首次发布于 2026/11/07;过去 12 个月发布了 17 个版本。

版本发布日期距今
0.4.4latest2026/11/122 天前
0.4.32026/11/112 天前
0.4.22026/11/112 天前
0.4.12026/11/112 天前
0.4.02026/11/103 天前
0.3.62026/11/094 天前

关于 @elysiumjs/themis 的常见问题

@elysiumjs/themis 是什么?

Authentication for the Elysium.js framework: passwords, tokens, sessions, guards, CSRF, TOTP, OAuth and passkeys.注意:该包已被作者标记为弃用——has moved into @elysiumjs/olympus: import from @elysiumjs/olympus/themis; passwords, JWT and signed URLs from @elysiumjs/olympus/harpocrates.

如何安装 @elysiumjs/themis?

在项目目录运行 npm install @elysiumjs/themis,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@elysiumjs/core(^0.24.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@elysiumjs/themis 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@elysiumjs/themis 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@elysiumjs/themis 可以商用吗?

@elysiumjs/themis 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@elysiumjs/themis 的下载量有多少?

根据 npm 官方下载统计,@elysiumjs/themis 在 2026/10/14 – 2026/11/12 这 30 天内被下载 3,135 次,日均约 105 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@elysiumjs/themis 还在维护吗?

最新版本 0.4.4 发布于 2026/11/12(2 天前);至今共发布 17 个版本,其中过去 12 个月发布了 17 个;首次发布于 2026/11/07。作者已将其标记为弃用,新项目建议寻找替代方案。