feat: supertokens at home (#7699)

This commit is contained in:
Laurin 2026-02-23 11:19:12 +01:00 committed by GitHub
parent fb0bea7bb3
commit 5f88ce8bd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3732 additions and 124 deletions

View file

@ -0,0 +1,76 @@
---
'hive': minor
---
Add experimental support for running without `supertokens` service.
## Instructions
### Prerequisites
Adjust your docker compose file like the following:
- Remove `services.supertokens` from your `docker-compose.community.yml` file
- Remove the following environment variables from the `services.server.environment`
- `SUPERTOKENS_CONNECTION_URI=`
- `SUPERTOKENS_API_KEY=`
- Set the following environment variables for `services.server.environment`
- `SUPERTOKENS_AT_HOME=1`
- `SUPERTOKENS_REFRESH_TOKEN_KEY=`
- `SUPERTOKENS_ACCESS_TOKEN_KEY=`
- Set the following environment variables for `services.migrations.environment`
- `SUPERTOKENS_AT_HOME=1`
### Set the refresh token key
#### Extract from existing `supertokens` deployment
This method works if you use supertokens before and want to have existing user sessions to continue working.
If you want to avoid messing with the database, you can also create a new refresh token key from scratch, the drawback is that users are forced to login again.
Extract the refresh token key from the supertokens database
```sql
SELECT "value" FROM "supertokens_key_value" WHERE "name" = 'refresh_token_key';
```
The key should look similar to this: `1000:15e5968d52a9a48921c1c63d88145441a8099b4a44248809a5e1e733411b3eeb80d87a6e10d3390468c222f6a91fef3427f8afc8b91ea1820ab10c7dfd54a268:39f72164821e08edd6ace99f3bd4e387f45fa4221fe3cd80ecfee614850bc5d647ac2fddc14462a00647fff78c22e8d01bc306a91294f5b889a90ba891bf0aa0`
Update the docker compose `services.server.environment.SUPERTOKENS_REFRESH_TOKEN_KEY` environment variable value to this string.
#### Create from scratch
Run the following command to create a new refresh key from scratch:
```sh
echo "1000:$(openssl rand -hex 64):$(openssl rand -hex 64)"
```
Update the docker compose `services.server.environment.SUPERTOKENS_REFRESH_TOKEN_KEY` environment variable value to this string.
### Set the access token key
Generate a new access token key using the following instructions:
```sh
# 1. Generate a unique key name. 'uuidgen' is great for this.
# You can replace this with any string you like, e.g., KEY_NAME="my-app-key-1"
KEY_NAME=$(uuidgen)
# 2. Generate a 2048-bit RSA private key in PEM format, held in memory.
PRIVATE_KEY_PEM=$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048)
# 3. Extract the corresponding public key from the private key, also held in memory.
PUBLIC_KEY_PEM=$(echo "$PRIVATE_KEY_PEM" | openssl rsa -pubout)
# 4. Strip the headers/footers and newlines from the private key PEM
# to get just the raw Base64 data.
PRIVATE_KEY_DATA=$(echo "$PRIVATE_KEY_PEM" | awk 'NF {if (NR!=1 && $0!~/-----END/) print}' | tr -d '\n')
# 5. Do the same for the public key PEM.
PUBLIC_KEY_DATA=$(echo "$PUBLIC_KEY_PEM" | awk 'NF {if (NR!=1 && $0!~/-----END/) print}' | tr -d '\n')
# 6. Echo the final formatted string to the console.
echo "${KEY_NAME}|${PUBLIC_KEY_DATA}|${PRIVATE_KEY_DATA}"
```
Update the docker compose `services.server.environment.SUPERTOKENS_ACCESS_TOKEN_KEY` environment variable value to the formatted string output.
## Conclusion
After performing this updates you can run Hive Console without the need for the `supertokens` service. All the relevant authentication logic resides within the `server` container instead.
Existing users in the supertokens system will continue to exist when running without the `supertokens` service.

View file

@ -1,3 +1,4 @@
import 'reflect-metadata';
import * as fs from 'node:fs';
// eslint-disable-next-line import/no-extraneous-dependencies -- cypress SHOULD be a dev dependency
import { defineConfig } from 'cypress';

View file

@ -2,7 +2,12 @@
"compilerOptions": {
"target": "es2021",
"lib": ["es2021", "dom"],
"types": ["node", "cypress"]
"types": ["node", "cypress"],
"baseUrl": "..",
"paths": {
"@hive/api/*": ["./packages/services/api/src/*"],
"@hive/server/*": ["./packages/services/server/src/*"]
}
},
"include": ["**/*.ts", "../integration-tests/testkit/**/*.ts"]
}

View file

@ -37,5 +37,9 @@ services:
ports:
- '3567:3567'
db:
ports:
- '5432:5432'
networks:
stack: {}

View file

@ -21,3 +21,6 @@ CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE=1000
EXTERNAL_COMPOSITION_SECRET=secretsecret
LIMIT_CACHE_UPDATE_INTERVAL_MS=2000
NODE_OPTIONS=--enable-source-maps
SUPERTOKENS_AT_HOME=0
SUPERTOKENS_REFRESH_TOKEN_KEY=1000:15e5968d52a9a48921c1c63d88145441a8099b4a44248809a5e1e733411b3eeb80d87a6e10d3390468c222f6a91fef3427f8afc8b91ea1820ab10c7dfd54a268:39f72164821e08edd6ace99f3bd4e387f45fa4221fe3cd80ecfee614850bc5d647ac2fddc14462a00647fff78c22e8d01bc306a91294f5b889a90ba891bf0aa0
SUPERTOKENS_ACCESS_TOKEN_KEY=s-aaa5da0d-8678-46ef-b56d-9cd19e1cdb5b|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

View file

@ -24,4 +24,7 @@ applyEnv({
CLICKHOUSE_USER: serverEnvVars.CLICKHOUSE_USERNAME,
CLICKHOUSE_PASSWORD: serverEnvVars.CLICKHOUSE_PASSWORD,
HIVE_ENCRYPTION_SECRET: serverEnvVars.HIVE_ENCRYPTION_SECRET,
SUPERTOKENS_AT_HOME: serverEnvVars.SUPERTOKENS_AT_HOME,
SUPERTOKENS_REFRESH_TOKEN_KEY: serverEnvVars.SUPERTOKENS_REFRESH_TOKEN_KEY,
SUPERTOKENS_ACCESS_TOKEN_KEY: serverEnvVars.SUPERTOKENS_ACCESS_TOKEN_KEY,
});

View file

@ -1,5 +1,13 @@
import { DatabasePool } from 'slonik';
import { z } from 'zod';
import {
AccessTokenKeyContainer,
hashPassword,
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
import type { InternalApi } from '@hive/server';
import { createNewSession } from '@hive/server/supertokens-at-home/shared';
import { createTRPCProxyClient, httpLink } from '@trpc/client';
import { ensureEnv } from './env';
import { getServiceHost } from './utils';
@ -57,6 +65,56 @@ const signUpUserViaEmail = async (
}
};
const createSessionAtHome = async (
supertokensStore: SuperTokensStore,
superTokensUserId: string,
email: string,
oidcIntegrationId: string | null,
) => {
const graphqlAddress = await getServiceHost('server', 8082);
const internalApi = createTRPCProxyClient<InternalApi>({
links: [
httpLink({
url: `http://${graphqlAddress}/trpc`,
fetch,
}),
],
});
const ensureUserResult = await internalApi.ensureUser.mutate({
superTokensUserId,
email,
oidcIntegrationId,
firstName: null,
lastName: null,
});
if (!ensureUserResult.ok) {
throw new Error(ensureUserResult.reason);
}
const session = await createNewSession(
supertokensStore,
{
superTokensUserId,
hiveUser: ensureUserResult.user,
oidcIntegrationId,
},
{
refreshTokenKey: process.env.SUPERTOKENS_REFRESH_TOKEN_KEY!,
accessTokenKey: new AccessTokenKeyContainer(process.env.SUPERTOKENS_ACCESS_TOKEN_KEY!),
},
);
/**
* These are the required cookies that need to be set.
*/
return {
access_token: session.accessToken.token,
refresh_token: session.refreshToken,
};
};
const createSessionPayload = (payload: {
superTokensUserId: string;
userId: string;
@ -155,31 +213,51 @@ const createSession = async (
};
const password = 'ilikebigturtlesandicannotlie47';
const hashedPassword = await hashPassword(password);
export function userEmail(userId: string) {
return `${userId}-${Date.now()}@localhost.localhost`;
}
const tokenResponsePromise: {
[key: string]: Promise<z.TypeOf<typeof SignUpSignInUserResponseModel>> | null;
[key: string]: Promise<{
userId: string;
email: string;
}> | null;
} = {};
export function authenticate(
export async function authenticate(
pool: DatabasePool,
email: string,
): Promise<{ access_token: string; refresh_token: string }>;
export function authenticate(
email: string,
oidcIntegrationId?: string,
): Promise<{ access_token: string; refresh_token: string }>;
export function authenticate(
email: string | string,
oidcIntegrationId?: string,
): Promise<{ access_token: string; refresh_token: string }> {
if (process.env.SUPERTOKENS_AT_HOME === '1') {
const supertokensStore = new SuperTokensStore(pool, new NoopLogger());
if (!tokenResponsePromise[email]) {
tokenResponsePromise[email] = supertokensStore.createEmailPasswordUser({
email,
passwordHash: hashedPassword,
});
}
const user = await tokenResponsePromise[email]!;
return await createSessionAtHome(
supertokensStore,
user.userId,
email,
oidcIntegrationId ?? null,
);
}
if (!tokenResponsePromise[email]) {
tokenResponsePromise[email] = signUpUserViaEmail(email, password);
tokenResponsePromise[email] = signUpUserViaEmail(email, password).then(res => ({
email: res.user.email,
userId: res.user.id,
}));
}
return tokenResponsePromise[email]!.then(data =>
createSession(data.user.id, data.user.email, oidcIntegrationId ?? null),
createSession(data.userId, data.email, oidcIntegrationId ?? null),
);
}

View file

@ -62,32 +62,44 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from
import { collect, CollectedOperation, legacyCollect } from './usage';
import { generateUnique, getServiceHost } from './utils';
export function initSeed() {
function createConnectionPool() {
const pg = {
user: ensureEnv('POSTGRES_USER'),
password: ensureEnv('POSTGRES_PASSWORD'),
host: ensureEnv('POSTGRES_HOST'),
port: ensureEnv('POSTGRES_PORT'),
db: ensureEnv('POSTGRES_DB'),
};
function createConnectionPool() {
const pg = {
user: ensureEnv('POSTGRES_USER'),
password: ensureEnv('POSTGRES_PASSWORD'),
host: ensureEnv('POSTGRES_HOST'),
port: ensureEnv('POSTGRES_PORT'),
db: ensureEnv('POSTGRES_DB'),
};
return createPool(
`postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`,
);
return createPool(
`postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`,
);
}
async function createDbConnection() {
const pool = await createConnectionPool();
return {
pool,
[Symbol.asyncDispose]: async () => {
await pool.end();
},
};
}
export function initSeed() {
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
async function doAuthenticate(email: string, oidcIntegrationId?: string) {
if (!sharedDBPoolPromise) {
sharedDBPoolPromise = createDbConnection();
}
const sharedPool = await sharedDBPoolPromise;
return await authenticate(sharedPool.pool, email, oidcIntegrationId);
}
return {
async createDbConnection() {
const pool = await createConnectionPool();
return {
pool,
[Symbol.asyncDispose]: async () => {
await pool.end();
},
};
},
authenticate,
createDbConnection,
authenticate: doAuthenticate,
generateEmail: () => userEmail(generateUnique()),
async purgeOrganizationAccessTokenById(id: string) {
const registryAddress = await getServiceHost('server', 8082);
@ -100,7 +112,7 @@ export function initSeed() {
},
async createOwner() {
const ownerEmail = userEmail(generateUnique());
const auth = await authenticate(ownerEmail);
const auth = await doAuthenticate(ownerEmail);
const ownerRefreshToken = auth.refresh_token;
const ownerToken = auth.access_token;
@ -904,7 +916,7 @@ export function initSeed() {
},
);
const memberEmail = userEmail(generateUnique());
const memberToken = await authenticate(memberEmail, oidcIntegrationId).then(
const memberToken = await doAuthenticate(memberEmail, oidcIntegrationId).then(
r => r.access_token,
);

View file

@ -26,6 +26,14 @@ export default defineConfig({
'../packages/services/service-common/src/index.ts',
import.meta.url,
).pathname,
'@hive/server/supertokens-at-home/shared': new URL(
'../packages/services/server/src/supertokens-at-home/shared.ts',
import.meta.url,
).pathname,
'@hive/api/modules/auth/lib/supertokens-at-home/crypto': new URL(
'../packages/services/api/src/modules/auth/lib/supertokens-at-home/crypto.ts',
import.meta.url,
).pathname,
},
setupFiles,
testTimeout: 90_000,

View file

@ -0,0 +1,168 @@
import { type MigrationExecutor } from '../pg-migrator';
export default {
name: '2026.02.18T00-00-00.ensure-supertokens-tables.ts',
run: ({ sql }) => [
{
name: 'seed required tables',
query: sql`
CREATE TABLE IF NOT EXISTS supertokens_apps (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
created_at_time int8 NULL,
CONSTRAINT supertokens_apps_pkey PRIMARY KEY (app_id)
);
CREATE TABLE IF NOT EXISTS supertokens_tenants (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
tenant_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
created_at_time int8 NULL,
CONSTRAINT supertokens_tenants_pkey PRIMARY KEY (app_id, tenant_id),
CONSTRAINT supertokens_tenants_app_id_fkey FOREIGN KEY (app_id) REFERENCES supertokens_apps(app_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tenants_app_id_index ON supertokens_tenants USING btree (app_id);
CREATE TABLE IF NOT EXISTS supertokens_session_info (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
tenant_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
session_handle varchar(255) NOT NULL,
user_id varchar(128) NOT NULL,
refresh_token_hash_2 varchar(128) NOT NULL,
session_data text NULL,
expires_at int8 NOT NULL,
created_at_time int8 NOT NULL,
jwt_user_payload text NULL,
use_static_key bool NOT NULL,
CONSTRAINT supertokens_session_info_pkey PRIMARY KEY (app_id, tenant_id, session_handle),
CONSTRAINT supertokens_session_info_tenant_id_fkey FOREIGN KEY (app_id, tenant_id) REFERENCES supertokens_tenants(app_id,tenant_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS session_expiry_index ON supertokens_session_info USING btree (expires_at);
CREATE INDEX IF NOT EXISTS session_info_tenant_id_index ON supertokens_session_info USING btree (app_id, tenant_id);
CREATE TABLE IF NOT EXISTS supertokens_app_id_to_user_id (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
recipe_id varchar(128) NOT NULL,
primary_or_recipe_user_id bpchar(36) NOT NULL,
is_linked_or_is_a_primary_user bool DEFAULT false NOT NULL,
CONSTRAINT supertokens_app_id_to_user_id_pkey PRIMARY KEY (app_id, user_id),
CONSTRAINT supertokens_app_id_to_user_id_app_id_fkey FOREIGN KEY (app_id) REFERENCES supertokens_apps(app_id) ON DELETE CASCADE,
CONSTRAINT supertokens_app_id_to_user_id_primary_or_recipe_user_id_fkey FOREIGN KEY (app_id,primary_or_recipe_user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS app_id_to_user_id_app_id_index ON supertokens_app_id_to_user_id USING btree (app_id);
CREATE INDEX IF NOT EXISTS app_id_to_user_id_primary_user_id_index ON supertokens_app_id_to_user_id USING btree (primary_or_recipe_user_id, app_id);
CREATE TABLE IF NOT EXISTS supertokens_emailpassword_users (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
email varchar(256) NOT NULL,
password_hash varchar(256) NOT NULL,
time_joined int8 NOT NULL,
CONSTRAINT supertokens_emailpassword_users_pkey PRIMARY KEY (app_id, user_id),
CONSTRAINT supertokens_emailpassword_users_user_id_fkey FOREIGN KEY (app_id,user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS supertokens_thirdparty_users (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
third_party_id varchar(28) NOT NULL,
third_party_user_id varchar(256) NOT NULL,
user_id bpchar(36) NOT NULL,
email varchar(256) NOT NULL,
time_joined int8 NOT NULL,
CONSTRAINT supertokens_thirdparty_users_pkey PRIMARY KEY (app_id, user_id),
CONSTRAINT supertokens_thirdparty_users_user_id_fkey FOREIGN KEY (app_id,user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS thirdparty_users_email_index ON supertokens_thirdparty_users USING btree (app_id, email);
CREATE INDEX IF NOT EXISTS thirdparty_users_thirdparty_user_id_index ON supertokens_thirdparty_users USING btree (app_id, third_party_id, third_party_user_id);
CREATE TABLE IF NOT EXISTS supertokens_all_auth_recipe_users (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
tenant_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
primary_or_recipe_user_id bpchar(36) NOT NULL,
is_linked_or_is_a_primary_user bool DEFAULT false NOT NULL,
recipe_id varchar(128) NOT NULL,
time_joined int8 NOT NULL,
primary_or_recipe_user_time_joined int8 NOT NULL,
CONSTRAINT supertokens_all_auth_recipe_users_pkey PRIMARY KEY (app_id, tenant_id, user_id),
CONSTRAINT supertokens_all_auth_recipe_users_primary_or_recipe_user_id_fke FOREIGN KEY (app_id,primary_or_recipe_user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE,
CONSTRAINT supertokens_all_auth_recipe_users_tenant_id_fkey FOREIGN KEY (app_id,tenant_id) REFERENCES supertokens_tenants(app_id,tenant_id) ON DELETE CASCADE,
CONSTRAINT supertokens_all_auth_recipe_users_user_id_fkey FOREIGN KEY (app_id,user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS all_auth_recipe_tenant_id_index ON supertokens_all_auth_recipe_users USING btree (app_id, tenant_id);
CREATE INDEX IF NOT EXISTS all_auth_recipe_user_id_index ON supertokens_all_auth_recipe_users USING btree (app_id, user_id);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_pagination_index1 ON supertokens_all_auth_recipe_users USING btree (app_id, tenant_id, primary_or_recipe_user_time_joined DESC, primary_or_recipe_user_id DESC);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_pagination_index2 ON supertokens_all_auth_recipe_users USING btree (app_id, tenant_id, primary_or_recipe_user_time_joined, primary_or_recipe_user_id DESC);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_pagination_index3 ON supertokens_all_auth_recipe_users USING btree (recipe_id, app_id, tenant_id, primary_or_recipe_user_time_joined DESC, primary_or_recipe_user_id DESC);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_pagination_index4 ON supertokens_all_auth_recipe_users USING btree (recipe_id, app_id, tenant_id, primary_or_recipe_user_time_joined, primary_or_recipe_user_id DESC);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_primary_user_id_index ON supertokens_all_auth_recipe_users USING btree (primary_or_recipe_user_id, app_id);
CREATE INDEX IF NOT EXISTS all_auth_recipe_users_recipe_id_index ON supertokens_all_auth_recipe_users USING btree (app_id, recipe_id, tenant_id);
CREATE TABLE IF NOT EXISTS supertokens_thirdparty_user_to_tenant (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
tenant_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
third_party_id varchar(28) NOT NULL,
third_party_user_id varchar(256) NOT NULL,
CONSTRAINT supertokens_thirdparty_user_to_tenant_pkey PRIMARY KEY (app_id, tenant_id, user_id),
CONSTRAINT supertokens_thirdparty_user_to_tenant_third_party_user_id_key UNIQUE (app_id, tenant_id, third_party_id, third_party_user_id),
CONSTRAINT supertokens_thirdparty_user_to_tenant_user_id_fkey FOREIGN KEY (app_id,tenant_id,user_id) REFERENCES supertokens_all_auth_recipe_users(app_id,tenant_id,user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS supertokens_emailpassword_user_to_tenant (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
tenant_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
email varchar(256) NOT NULL,
CONSTRAINT supertokens_emailpassword_user_to_tenant_email_key UNIQUE (app_id, tenant_id, email),
CONSTRAINT supertokens_emailpassword_user_to_tenant_pkey PRIMARY KEY (app_id, tenant_id, user_id),
CONSTRAINT supertokens_emailpassword_user_to_tenant_user_id_fkey FOREIGN KEY (app_id,tenant_id,user_id) REFERENCES supertokens_all_auth_recipe_users(app_id,tenant_id,user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS supertokens_emailpassword_pswd_reset_tokens (
app_id varchar(64) DEFAULT 'public'::character varying NOT NULL,
user_id bpchar(36) NOT NULL,
"token" varchar(128) NOT NULL,
email varchar(256) NULL,
token_expiry int8 NOT NULL,
CONSTRAINT supertokens_emailpassword_pswd_reset_tokens_pkey PRIMARY KEY (app_id, user_id, token),
CONSTRAINT supertokens_emailpassword_pswd_reset_tokens_token_key UNIQUE (token),
CONSTRAINT supertokens_emailpassword_pswd_reset_tokens_user_id_fkey FOREIGN KEY (app_id,user_id) REFERENCES supertokens_app_id_to_user_id(app_id,user_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS emailpassword_password_reset_token_expiry_index ON supertokens_emailpassword_pswd_reset_tokens USING btree (token_expiry);
CREATE INDEX IF NOT EXISTS emailpassword_pswd_reset_tokens_user_id_index ON supertokens_emailpassword_pswd_reset_tokens USING btree (app_id, user_id);
`,
},
{
name: 'ensure app exists',
query: sql`
INSERT INTO "supertokens_apps" (
app_id
, created_at_time
) VALUES (
'public'
, ${Date.now()}
)
ON CONFLICT (app_id) DO NOTHING
`,
},
{
name: 'ensure app exists',
query: sql`
INSERT INTO "supertokens_tenants" (
app_id
, tenant_id
, created_at_time
) VALUES (
'public'
, 'public'
, ${Date.now()}
)
ON CONFLICT (app_id, tenant_id) DO NOTHING;
`,
},
],
} satisfies MigrationExecutor;

View file

@ -34,6 +34,7 @@ const EnvironmentModel = zod.object({
.union([zod.literal('1'), zod.literal('0')])
.optional(),
GRAPHQL_HIVE_ENVIRONMENT: emptyString(zod.enum(['prod', 'staging', 'dev']).optional()),
SUPERTOKENS_AT_HOME: zod.union([zod.literal('1'), zod.literal('0')]).optional(),
});
const PostgresModel = zod.object({
@ -114,4 +115,5 @@ export const env = {
isClickHouseMigrator: base.CLICKHOUSE_MIGRATOR === 'up',
isHiveCloud: base.CLICKHOUSE_MIGRATOR_GRAPHQL_HIVE_CLOUD === '1',
hiveCloudEnvironment: base.GRAPHQL_HIVE_ENVIRONMENT ?? null,
useSupertokensAtHome: base.SUPERTOKENS_AT_HOME === '1',
} as const;

View file

@ -67,6 +67,7 @@ import migration_2024_06_11T10_10_00_ms_teams_webhook from './actions/2024.06.11
import migration_2024_07_16T13_44_00_oidc_only_access from './actions/2024.07.16T13-44-00.oidc-only-access';
import migration_2024_07_17T00_00_00_app_deployments from './actions/2024.07.17T00-00-00.app-deployments';
import migration_2024_07_23T_09_36_00_schema_cleanup_tracker from './actions/2024.07.23T09.36.00.schema-cleanup-tracker';
import { env } from './environment';
import { runMigrations } from './pg-migrator';
export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: string }) =>
@ -183,5 +184,8 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
await import('./actions/2026.01.30T00-00-00.account-linking'),
await import('./actions/2026.02.06T00-00-00.zendesk-unique'),
await import('./actions/2026.01.30T10-00-00.oidc-require-invitation'),
...(env.useSupertokensAtHome
? [await import('./actions/2026.02.18T00-00-00.ensure-supertokens-tables')]
: []),
],
});

View file

@ -6,6 +6,11 @@
"engines": {
"node": ">=12"
},
"exports": {
"./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts",
"./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts",
"./modules/shared/providers/logger": "./src/modules/shared/providers/logger"
},
"peerDependencies": {
"graphql": "^16.0.0",
"reflect-metadata": "0.2.2"
@ -57,6 +62,7 @@
"graphql-yoga": "5.13.3",
"ioredis": "5.8.2",
"ioredis-mock": "8.9.0",
"jsonwebtoken": "9.0.3",
"lodash": "4.17.23",
"lru-cache": "7.18.3",
"ms": "2.1.3",

View file

@ -61,6 +61,7 @@ import { PG_POOL_CONFIG } from './modules/shared/providers/pg-pool';
import { PrometheusConfig } from './modules/shared/providers/prometheus-config';
import { HivePubSub, PUB_SUB_CONFIG } from './modules/shared/providers/pub-sub';
import { REDIS_INSTANCE } from './modules/shared/providers/redis';
import { RedisRateLimiter } from './modules/shared/providers/redis-rate-limiter';
import { S3_CONFIG, type S3Config } from './modules/shared/providers/s3-config';
import { Storage } from './modules/shared/providers/storage';
import { RateLimitConfig, WEB_APP_URL } from './modules/shared/providers/tokens';
@ -218,6 +219,7 @@ export function createRegistry({
CryptoProvider,
InMemoryRateLimitStore,
InMemoryRateLimiter,
RedisRateLimiter,
{
provide: AuditLogS3Config,
useValue: auditLogS3Config,

View file

@ -2,6 +2,7 @@ import { createModule } from 'graphql-modules';
import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager';
import { AuthManager } from './providers/auth-manager';
import { EmailVerification } from './providers/email-verification';
import { OAuthCache } from './providers/oauth-cache';
import { OrganizationAccessTokenValidationCache } from './providers/organization-access-token-validation-cache';
import { UserManager } from './providers/user-manager';
import { resolvers } from './resolvers.generated';
@ -18,5 +19,6 @@ export const authModule = createModule({
UserManager,
AuditLogManager,
OrganizationAccessTokenValidationCache,
OAuthCache,
],
});

View file

@ -0,0 +1,263 @@
/**
* A collection of supertokens crypto utilities around.
* - Refresh Tokens
* - Access Tokens
* - Front Tokens
*/
import * as c from 'node:crypto';
import { promisify } from 'node:util';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import z from 'zod';
export async function hashPassword(plaintextPassword: string): Promise<string> {
// The "cost factor" or salt rounds. 10 is a good, standard balance of security and performance.
// This value is included in the final hash string itself.
const saltRounds = 10;
// bcrypt.hash handles the generation of a random salt and the hashing process.
// The operation is asynchronous to prevent blocking the event loop.
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
return hash;
}
export async function comparePassword(password: string, hash: string) {
return await bcrypt.compare(password, hash);
}
const pbkdf2Async = promisify(c.pbkdf2);
async function decryptRefreshToken(encodedData: string, masterKey: string) {
// 1. Decode the incoming string (URL -> Base64 -> Buffer).
const urlDecodedData = decodeURIComponent(encodedData);
const buffer = Buffer.from(urlDecodedData, 'base64');
// 2. Deconstruct the buffer based on the Java encryption logic.
// The first 12 bytes are the IV.
const iv = buffer.slice(0, 12);
// The rest of the buffer is the encrypted data + 16-byte auth tag.
const encryptedPayload = buffer.slice(12);
// 3. Re-derive the secret key using PBKDF2. This is the critical step.
// The parameters MUST match the Java side exactly.
// The IV is used as the salt.
const iterations = 100;
const keylen = 32; // 32 bytes = 256 bits
const digest = 'sha512'; // NOTE: This is a guess. See explanation below.
const secretKey = await pbkdf2Async(masterKey, iv, iterations, keylen, digest);
// 4. Separate the encrypted data from the authentication tag.
const authTagLength = 16; // 128 bits
const encryptedData = encryptedPayload.slice(0, -authTagLength);
const authTag = encryptedPayload.slice(-authTagLength);
// 5. Perform the decryption with the derived key and IV.
const decipher = c.createDecipheriv('aes-256-gcm', secretKey, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encryptedData), decipher.final()]).toString('utf-8');
}
function encryptRefreshToken(plaintext: string, masterKey: string) {
// 1. Generate a random 12-byte IV (Initialization Vector), same as the Java side.
const iv = c.randomBytes(12);
// 2. Derive the secret key using PBKDF2. The IV is used as the salt.
// The parameters (iterations, key length, and digest) match the Java implementation.
const iterations = 100;
const keylen = 32; // 32 bytes = 256 bits
const digest = 'sha512'; // From "PBKDF2WithHmacSHA512"
const secretKey = c.pbkdf2Sync(masterKey, iv, iterations, keylen, digest);
// 3. Create the AES-256-GCM cipher.
const cipher = c.createCipheriv('aes-256-gcm', secretKey, iv);
// 4. Encrypt the plaintext.
// The result is the ciphertext.
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
// 5. Get the 16-byte authentication tag.
// This is a crucial step in GCM.
const authTag = cipher.getAuthTag();
// 6. Concatenate everything in the correct order: IV + Ciphertext + Auth Tag.
// This matches the Java `ByteBuffer` logic.
const finalBuffer = Buffer.concat([iv, encrypted, authTag]);
// 7. Base64-encode the final buffer
const base64Data = finalBuffer.toString('base64');
return base64Data;
}
export function createRefreshToken(
args: {
sessionHandle: string;
userId: string;
parentRefreshTokenHash1: string | null;
},
masterKey: string,
) {
const newNonce = sha256(crypto.randomUUID());
const encryptedPayload = encryptRefreshToken(
JSON.stringify({
sessionHandle: args.sessionHandle,
userId: args.userId,
nonce: newNonce,
parentRefreshTokenHash1: args.parentRefreshTokenHash1 ?? undefined,
}),
masterKey,
);
const refreshToken = encryptedPayload + '.' + newNonce + '.' + 'V2';
return refreshToken;
}
const RefreshTokenPayloadModel = z.object({
sessionHandle: z.string(),
userId: z.string(),
parentRefreshTokenHash1: z.string().optional(),
nonce: z.string(),
});
type RefreshTokenPayloadType = z.TypeOf<typeof RefreshTokenPayloadModel>;
export async function parseRefreshToken(refreshToken: string, masterKey: string) {
const [payload, nonce, version] = refreshToken.split('.');
if (version !== 'V2') {
return {
type: 'error',
code: 'ERR_INVALID_FORMAT',
} as const;
}
let refreshTokenPayload: RefreshTokenPayloadType;
try {
refreshTokenPayload = RefreshTokenPayloadModel.parse(
JSON.parse(await decryptRefreshToken(payload, masterKey)),
);
} catch (err) {
return {
type: 'error',
code: 'ERR_INVALID_PAYLOAD',
} as const;
}
if (refreshTokenPayload.nonce !== nonce) {
return {
type: 'error',
code: 'ERR_INVALID_NONCE',
} as const;
}
return {
type: 'success',
payload: refreshTokenPayload,
} as const;
}
export function sha256(str: string) {
return c.createHash('sha256').update(str).digest('hex');
}
export function createAccessToken(
args: {
sub: string;
sessionHandle: string;
refreshTokenHash1: string;
parentRefreshTokenHash1: string | null;
sessionData: Record<string, unknown>;
},
accessTokenKey: AccessTokenKeyContainer,
) {
const now = Math.floor(Date.now() / 1000);
// Access tokens expires in 6 hours
const expiresIn = Math.floor(now + 60 * 60 * 6 * 1000);
const data: AccessTokenInfo = {
iat: now,
exp: expiresIn,
sub: args.sub,
tId: 'public',
rsub: args.sub,
sessionHandle: args.sessionHandle,
antiCsrfToken: null,
refreshTokenHash1: args.refreshTokenHash1,
parentRefreshTokenHash1: args.parentRefreshTokenHash1 ?? undefined,
...args.sessionData,
};
const token = jwt.sign(data, accessTokenKey.privateKey, {
header: {
kid: accessTokenKey.keyId,
typ: 'JWT',
alg: 'RS256',
},
});
return { token, expiresIn, d: jwt.decode(token) };
}
export function isAccessToken(accessToken: string) {
return !!jwt.decode(accessToken);
}
export function parseAccessToken(accessToken: string, accessTokenPublicKey: string) {
const token = jwt.verify(accessToken, accessTokenPublicKey, {
algorithms: ['RS256'],
});
return AccessTokenInfoModel.parse(token);
}
export function createFrontToken(args: {
superTokensUserId: string;
accessToken: ReturnType<typeof createAccessToken>;
}) {
return Buffer.from(
JSON.stringify({
uid: args.superTokensUserId,
ate: args.accessToken.expiresIn * 1000,
up: args.accessToken.d,
}),
).toString('base64');
}
const AccessTokenInfoModel = z.object({
iat: z.number(),
exp: z.number(),
sub: z.string(),
tId: z.string(),
rsub: z.string(),
sessionHandle: z.string(),
refreshTokenHash1: z.string(),
parentRefreshTokenHash1: z.string().optional(), // Making this optional as it may not always be present
antiCsrfToken: z.string().nullable(),
});
type AccessTokenInfo = z.TypeOf<typeof AccessTokenInfoModel>;
export function getPasswordResetHash() {
return c.randomBytes(32).toString('hex');
}
export class AccessTokenKeyContainer {
readonly keyId: string;
readonly publicKey: string;
readonly privateKey: string;
constructor(accessTokenKey: string) {
const [keyName, publicKey, privateKey] = accessTokenKey.split('|');
this.keyId = keyName;
this.publicKey = `-----BEGIN PUBLIC KEY-----\n` + publicKey + `\n-----END PUBLIC KEY-----`;
this.privateKey = `-----BEGIN PRIVATE KEY-----\n` + privateKey + `\n-----END PRIVATE KEY-----`;
}
}

View file

@ -1,3 +1,5 @@
import c from 'node:crypto';
import { parse as parseCookie } from 'cookie-es';
import SessionNode from 'supertokens-node/recipe/session/index.js';
import * as zod from 'zod';
import type { FastifyReply, FastifyRequest } from '@hive/service-common';
@ -8,7 +10,17 @@ import { OrganizationMembers } from '../../organization/providers/organization-m
import { Logger } from '../../shared/providers/logger';
import type { Storage } from '../../shared/providers/storage';
import { EmailVerification } from '../providers/email-verification';
import { SessionInfo, SuperTokensStore } from '../providers/supertokens-store';
import { AuthNStrategy, AuthorizationPolicyStatement, Session, UserActor } from './authz';
import {
AccessTokenKeyContainer,
isAccessToken,
parseAccessToken,
} from './supertokens-at-home/crypto';
function sha256(str: string) {
return c.createHash('sha256').update(str).digest('hex');
}
export class SuperTokensCookieBasedSession extends Session {
public superTokensUserId: string;
@ -155,32 +167,27 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
private logger: Logger;
private organizationMembers: OrganizationMembers;
private storage: Storage;
private supertokensStore: SuperTokensStore;
private emailVerification: EmailVerification | null;
private accessTokenKey: AccessTokenKeyContainer | null;
constructor(deps: {
logger: Logger;
storage: Storage;
organizationMembers: OrganizationMembers;
emailVerification: EmailVerification | null;
accessTokenKey: AccessTokenKeyContainer | null;
}) {
super();
this.logger = deps.logger.child({ module: 'SuperTokensUserAuthNStrategy' });
this.organizationMembers = deps.organizationMembers;
this.storage = deps.storage;
this.emailVerification = deps.emailVerification;
this.supertokensStore = new SuperTokensStore(deps.storage.pool, deps.logger);
this.accessTokenKey = deps.accessTokenKey;
}
private async verifySuperTokensSession(args: {
req: FastifyRequest;
reply: FastifyReply;
}): Promise<SuperTokensSessionPayload | null> {
this.logger.debug('Attempt verifying SuperTokens session');
if (args.req.headers['ignore-session']) {
this.logger.debug('Ignoring session due to header');
return null;
}
private async _verifySuperTokensCoreSession(args: { req: FastifyRequest; reply: FastifyReply }) {
let session: SessionNode.SessionContainer | undefined;
try {
@ -219,11 +226,6 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
const payload = session.getAccessTokenPayload();
if (!payload) {
this.logger.error('No access token payload found');
return null;
}
const result = SuperTokensSessionPayloadModel.safeParse(payload);
if (result.success === false) {
@ -240,13 +242,133 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
});
}
return result.data;
}
private async _verifySuperTokensAtHomeSession(
args: {
req: FastifyRequest;
reply: FastifyReply;
},
accessTokenKey: AccessTokenKeyContainer,
) {
let session: SessionInfo | null = null;
args.req.log.debug('attempt parsing access token from cookie');
const cookie = parseCookie(args.req.headers.cookie ?? '');
let rawAccessToken: string | undefined = cookie['sAccessToken'];
if (!rawAccessToken) {
args.req.log.debug('attempt parsing access token authorization header');
rawAccessToken = args.req.headers.authorization?.replace('Bearer ', '')?.trim();
}
if (!rawAccessToken || !isAccessToken(rawAccessToken)) {
args.req.log.debug('access token is not identified as a supertokens access token.');
return null;
}
let accessToken;
try {
accessToken = parseAccessToken(rawAccessToken, accessTokenKey.publicKey);
} catch (err) {
args.req.log.debug('Failed verifying the access token. Ask for refresh.');
throw new HiveError('Invalid session', {
extensions: {
code: 'NEEDS_REFRESH',
},
});
}
if (accessToken.exp < Date.now() / 1000) {
args.req.log.debug('The access token is expired. Ask for refresh.');
throw new HiveError('Invalid session', {
extensions: {
code: 'NEEDS_REFRESH',
},
});
}
session = await this.supertokensStore.getSessionInfo(accessToken.sessionHandle);
if (!session) {
args.req.log.debug('The access token is expired, no session was found. Ask for refresh.');
return null;
}
if (session.expiresAt < Date.now()) {
args.req.log.debug('The session is expired.');
throw new HiveError('Invalid session.', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
if (
accessToken.parentRefreshTokenHash1 &&
sha256(accessToken.parentRefreshTokenHash1) !== session.refreshTokenHash2
) {
args.req.log.debug(
'The access token is expired. A new access token has been issued for this session. Require refresh.',
);
// old access token in use, there was alreadya refresh
throw new HiveError('Invalid session.', {
extensions: {
code: 'NEEDS_REFRESH',
},
});
}
const result = SuperTokensSessionPayloadModel.safeParse(JSON.parse(session.sessionData));
if (result.success === false) {
this.logger.error('SuperTokens session payload is invalid');
this.logger.debug('SuperTokens session payload: %s', session.sessionData);
this.logger.debug(
'SuperTokens session parsing errors: %s',
JSON.stringify(result.error.flatten().fieldErrors),
);
throw new HiveError('Invalid access token provided', {
extensions: {
code: 'UNAUTHENTICATED',
},
});
}
return result.data;
}
private async verifySuperTokensSession(args: {
req: FastifyRequest;
reply: FastifyReply;
}): Promise<SuperTokensSessionPayload | null> {
this.logger.debug('Attempt verifying SuperTokens session');
if (args.req.headers['ignore-session']) {
this.logger.debug('Ignoring session due to header');
return null;
}
const sessionData = this.accessTokenKey
? await this._verifySuperTokensAtHomeSession(args, this.accessTokenKey)
: await this._verifySuperTokensCoreSession(args);
if (!sessionData) {
this.logger.debug('No session found');
return null;
}
if (this.emailVerification) {
// Check whether the email is already verified.
// If it is not then we need to redirect to the email verification page - which will trigger the email sending.
const { verified } = await this.emailVerification.checkUserEmailVerified({
userIdentityId: session.getUserId(),
email: result.data.email,
userIdentityId: sessionData.superTokensUserId,
email: sessionData.email,
});
if (!verified) {
throw new HiveError('Your account is not verified. Please verify your email address.', {
extensions: {
@ -257,7 +379,7 @@ export class SuperTokensUserAuthNStrategy extends AuthNStrategy<SuperTokensCooki
}
this.logger.debug('SuperTokens session resolved.');
return result.data;
return sessionData;
}
async parse(args: {

View file

@ -0,0 +1,46 @@
import { Inject, Injectable, Scope } from 'graphql-modules';
import { type Redis } from 'ioredis';
import z from 'zod';
import { Logger } from '../../shared/providers/logger';
import { REDIS_INSTANCE } from '../../shared/providers/redis';
import { sha256 } from '../lib/supertokens-at-home/crypto';
@Injectable({
scope: Scope.Singleton,
global: true,
})
export class OAuthCache {
private logger: Logger;
constructor(
@Inject(REDIS_INSTANCE) private redis: Redis,
logger: Logger,
) {
this.logger = logger.child({ module: 'OAuthCache' });
}
async put(state: string, data: Record) {
const encodedData = JSON.stringify(data);
const key = `oauth-cache:${sha256(state)}`;
await this.redis.set(key, encodedData);
await this.redis.expire(key, 60 * 5);
}
async get(state: string) {
const key = `oauth-cache:${sha256(state)}`;
const encodedData = await this.redis.getdel(key);
if (!encodedData) {
return null;
}
const data = RecordModel.parse(JSON.parse(encodedData));
return data;
}
}
const RecordModel = z.object({
oidIntegrationId: z.string().nullable(),
pkceVerifier: z.string(),
method: z.string(),
});
type Record = z.TypeOf<typeof RecordModel>;

View file

@ -0,0 +1,598 @@
import { Inject } from 'graphql-modules';
import { sql, type DatabasePool } from 'slonik';
import z from 'zod';
import { Logger } from '../../shared/providers/logger';
import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool';
const SessionInfoModel = z.object({
sessionHandle: z.string(),
userId: z.string(),
sessionData: z.string(),
expiresAt: z.number(),
createdAt: z.number(),
refreshTokenHash2: z.string(),
});
export type SessionInfo = z.TypeOf<typeof SessionInfoModel>;
const UserModel = z.object({
userId: z.string(),
recipeId: z.union([z.literal('emailpassword'), z.literal('thirdparty')]),
});
const EmailPasswordUserModel = z.object({
userId: z.string(),
email: z.string(),
passwordHash: z.string(),
timeJoined: z.number(),
});
type EmailPasswordUser = z.TypeOf<typeof EmailPasswordUserModel>;
const ThirdpartUserModel = z.object({
thirdPartyId: z.string(),
thirdPartyUserId: z.string(),
userId: z.string(),
email: z.string(),
timeJoined: z.number(),
});
const EmailPasswordOrThirdPartyUserModel = z.union([EmailPasswordUserModel, ThirdpartUserModel]);
export type EmailPasswordOrThirdPartyUser = z.TypeOf<typeof EmailPasswordOrThirdPartyUserModel>;
const EmailPasswordResetTokenModel = z.object({
userId: z.string(),
token: z.string(),
email: z.string(),
tokenExpiry: z.number(),
});
export class SuperTokensStore {
private logger: Logger;
constructor(
@Inject(PG_POOL_CONFIG) private pool: DatabasePool,
logger: Logger,
) {
this.logger = logger.child({ module: 'SuperTokensStore' });
}
async getSessionInfo(sessionHandle: string) {
this.logger.debug('Lookup session. (sessionHandle=%s)', sessionHandle);
const query = sql`
SELECT
"session_handle" AS "sessionHandle"
, "user_id" AS "userId"
, "session_data" AS "sessionData"
, "expires_at" AS "expiresAt"
, "created_at_time" AS "createdAt"
, "refresh_token_hash_2" AS "refreshTokenHash2"
FROM
"supertokens_session_info"
WHERE
"app_id" = 'public'
AND "tenant_id" = 'public'
AND "session_handle" = ${sessionHandle}
`;
const result = await this.pool.maybeOne(query);
const record = SessionInfoModel.nullable().parse(result);
if (!record) {
this.logger.debug('Session not found (sessionHandle=%s)', sessionHandle);
return null;
}
this.logger.debug('Session found (sessionHandle=%s)', sessionHandle);
return record;
}
async deleteSession(sessionHandle: string) {
this.logger.debug('Delete session. (sessionHandle=%s)', sessionHandle);
const query = sql`
DELETE
FROM "supertokens_session_info"
WHERE
"app_id" = 'public'
AND "tenant_id" = 'public'
AND "session_handle" = ${sessionHandle}
`;
await this.pool.query(query);
}
async findEmailPasswordUserByEmail(email: string) {
const query = sql`
SELECT
"user_id" AS "userId"
, "email" AS "email"
, "password_hash" AS "passwordHash"
, "time_joined" AS "timeJoined"
FROM
"supertokens_emailpassword_users"
WHERE
"app_id" = 'public'
AND "email" = lower(${email})
`;
const record = await this.pool.maybeOne(query).then(EmailPasswordUserModel.nullable().parse);
return record;
}
private async lookupEmailUserByUserId(userId: string) {
const query = sql`
SELECT
"user_id" AS "userId"
, "email" AS "email"
, "password_hash" AS "passwordHash"
, "time_joined" AS "timeJoined"
FROM
"supertokens_emailpassword_users"
WHERE
"app_id" = 'public'
AND "user_id" = ${userId}
`;
const record = await this.pool.maybeOne(query).then(EmailPasswordUserModel.nullable().parse);
return record;
}
public async lookupEmailUserByEmail(email: string) {
const userToTenantQuery = sql`
SELECT
"user_id" AS "userId"
FROM
"supertokens_emailpassword_user_to_tenant"
WHERE
"app_id" = 'public'
AND "tenant_id" = 'public'
AND "email" = lower(${email})
`;
const userId = await this.pool
.maybeOneFirst(userToTenantQuery)
.then(z.string().nullable().parse);
if (!userId) {
return null;
}
const query = sql`
SELECT
"user_id" AS "userId"
, "email" AS "email"
, "password_hash" AS "passwordHash"
, "time_joined" AS "timeJoined"
FROM
"supertokens_emailpassword_users"
WHERE
"app_id" = 'public'
AND "user_id" = ${userId}
`;
const record = await this.pool.maybeOne(query).then(EmailPasswordUserModel.nullable().parse);
return record;
}
private async lookupThirdPartyUserByUserId(userId: string) {
const query = sql`
SELECT
"user_id" AS "userId"
, "email" AS "email"
, "third_party_id" AS "thirdPartyId"
, "third_party_user_id" AS "thirdPartyUserId"
, "time_joined" AS "timeJoined"
FROM
"supertokens_thirdparty_users"
WHERE
"app_id" = 'public'
AND "user_id" = ${userId}
`;
const record = await this.pool.maybeOne(query).then(ThirdpartUserModel.nullable().parse);
return record;
}
async lookupUserByUserId(userId: string) {
this.logger.debug('Lookup user. (userId=%s)', userId);
const query = sql`
SELECT
"user_id" AS "userId"
, "recipe_id" AS "recipeId"
FROM
"supertokens_all_auth_recipe_users"
WHERE
"app_id" = 'public'
AND "tenant_id" = 'public'
AND "user_id" = ${userId}
`;
const record = await this.pool.maybeOne(query).then(UserModel.nullable().parse);
if (!record) {
return null;
}
if (record.recipeId === 'emailpassword') {
return this.lookupEmailUserByUserId(record.userId);
}
if (record.recipeId === 'thirdparty') {
return this.lookupThirdPartyUserByUserId(record.userId);
}
return null;
}
async createSession(
sessionHandle: string,
userId: string,
sessionDataInDatabase: string,
accessTokenPayload: string,
refreshTokenHash2: string,
expiresAt: number,
) {
const query = sql`
INSERT INTO "supertokens_session_info" (
"app_id"
, "tenant_id"
, "session_handle"
, "user_id"
, "refresh_token_hash_2"
, "session_data"
, "expires_at"
, "jwt_user_payload"
, "use_static_key"
, "created_at_time"
) VALUES (
'public'
, 'public'
, ${sessionHandle}
, ${userId}
, ${refreshTokenHash2}
, ${sessionDataInDatabase}
, ${expiresAt}
, ${accessTokenPayload}
, false
, ${Date.now()}
)
RETURNING
"session_handle" AS "sessionHandle"
, "user_id" AS "userId"
, "session_data" AS "sessionData"
, "expires_at" AS "expiresAt"
, "created_at_time" AS "createdAt"
, "refresh_token_hash_2" AS "refreshTokenHash2"
`;
return await this.pool.one(query).then(SessionInfoModel.parse);
}
async updateSessionRefreshHash(
sessionHandle: string,
lastRefreshTokenHash2: string,
newRefreshTokenHash2: string,
) {
const query = sql`
UPDATE
"supertokens_session_info"
SET
"refresh_token_hash_2" = ${newRefreshTokenHash2}
WHERE
"app_id" = 'public'
AND "tenant_id" = 'public'
AND "session_handle" = ${sessionHandle}
AND "refresh_token_hash_2" = ${lastRefreshTokenHash2}
RETURNING
"session_handle" AS "sessionHandle"
, "user_id" AS "userId"
, "session_data" AS "sessionData"
, "expires_at" AS "expiresAt"
, "created_at_time" AS "createdAt"
, "refresh_token_hash_2" AS "refreshTokenHash2"
`;
return await this.pool.maybeOne(query).then(SessionInfoModel.nullable().parse);
}
async findThirdPartyUser(args: { thirdPartyId: string; thirdPartyUserId: string }) {
const query = sql`
SELECT
"user_id" AS "userId"
, "email" AS "email"
, "third_party_id" AS "thirdPartyId"
, "third_party_user_id" AS "thirdPartyUserId"
, "time_joined" AS "timeJoined"
FROM
"supertokens_thirdparty_users"
WHERE
"app_id" = 'public'
AND "third_party_id" = ${args.thirdPartyId}
AND "third_party_user_id" = ${args.thirdPartyUserId}
`;
return await this.pool.maybeOne(query).then(ThirdpartUserModel.nullable().parse);
}
async findOIDCUserBySubAndOIDCIntegrationId(args: { sub: string; oidcIntegrationId: string }) {
return this.findThirdPartyUser({
thirdPartyId: 'oidc',
thirdPartyUserId: `${args.oidcIntegrationId}-${args.sub}`,
});
}
async createThirdPartyUser(args: {
email: string;
thirdPartyId: string;
thirdPartyUserId: string;
}) {
const userId = crypto.randomUUID();
const now = Date.now();
const allRecipeUsersQuery = sql`
INSERT INTO "supertokens_all_auth_recipe_users" (
"app_id"
, "tenant_id"
, "user_id"
, "primary_or_recipe_user_id"
, "is_linked_or_is_a_primary_user"
, "recipe_id"
, "time_joined"
, "primary_or_recipe_user_time_joined"
) VALUES (
'public'
, 'public'
, ${userId}
, ${userId}
, false
, 'thirdparty'
, ${now}
, ${now}
)
`;
const oidcUserQuery = sql`
INSERT INTO "supertokens_thirdparty_users" (
"app_id"
, "third_party_id"
, "third_party_user_id"
, "user_id"
, "email"
, "time_joined"
) VALUES (
'public'
, ${args.thirdPartyId}
, ${args.thirdPartyUserId}
, ${userId}
, ${args.email}
, ${now}
)
RETURNING
"user_id" AS "userId"
, "email" AS "email"
, "third_party_id" AS "thirdPartyId"
, "third_party_user_id" AS "thirdPartyUserId"
, "time_joined" AS "timeJoined"
`;
const appIdToUserIdQuery = sql`
INSERT INTO "supertokens_app_id_to_user_id" (
"app_id"
, "user_id"
, "recipe_id"
, "primary_or_recipe_user_id"
, "is_linked_or_is_a_primary_user"
) VALUES (
'public'
, ${userId}
, 'thirdparty'
, ${userId}
, false
)
`;
const thirdpartyUserToTenant = sql`
INSERT INTO "supertokens_thirdparty_user_to_tenant" (
"app_id"
, "tenant_id"
, "user_id"
, "third_party_id"
, "third_party_user_id"
) VALUES (
'public'
, 'public'
, ${userId}
, ${args.thirdPartyId}
, ${args.thirdPartyUserId}
)
`;
return await this.pool
.transaction(async t => {
await t.query(appIdToUserIdQuery);
const result = await t.one(oidcUserQuery);
await t.query(allRecipeUsersQuery);
await t.query(thirdpartyUserToTenant);
return result;
})
.then(r => ThirdpartUserModel.parse(r));
}
async createOIDCUser(args: { email: string; sub: string; oidcIntegrationId: string }) {
return this.createThirdPartyUser({
email: args.email,
thirdPartyId: 'oidc',
thirdPartyUserId: args.oidcIntegrationId + '-' + args.sub,
});
}
async createEmailPasswordUser(args: { email: string; passwordHash: string }) {
const userId = crypto.randomUUID();
const now = Date.now();
const allRecipeUsersQuery = sql`
INSERT INTO "supertokens_all_auth_recipe_users" (
"app_id"
, "tenant_id"
, "user_id"
, "primary_or_recipe_user_id"
, "is_linked_or_is_a_primary_user"
, "recipe_id"
, "time_joined"
, "primary_or_recipe_user_time_joined"
) VALUES (
'public'
, 'public'
, ${userId}
, ${userId}
, false
, 'emailpassword'
, ${now}
, ${now}
)
`;
const emailPasswordUserQuery = sql`
INSERT INTO "supertokens_emailpassword_users" (
"app_id"
, "user_id"
, "email"
, "password_hash"
, "time_joined"
) VALUES (
'public'
, ${userId}
, lower(${args.email})
, ${args.passwordHash}
, ${now}
)
RETURNING
"user_id" AS "userId"
, "email" AS "email"
, "password_hash" AS "passwordHash"
, "time_joined" AS "timeJoined"
`;
const appIdToUserIdQuery = sql`
INSERT INTO "supertokens_app_id_to_user_id" (
"app_id"
, "user_id"
, "recipe_id"
, "primary_or_recipe_user_id"
, "is_linked_or_is_a_primary_user"
) VALUES (
'public'
, ${userId}
, 'emailpassword'
, ${userId}
, false
)
`;
const userToTenantQuery = sql`
INSERT INTO "supertokens_emailpassword_user_to_tenant" (
"app_id"
, "tenant_id"
, "user_id"
, "email"
) VALUES (
'public'
, 'public'
, ${userId}
, lower(${args.email})
)
`;
return await this.pool
.transaction(async t => {
await t.query(appIdToUserIdQuery);
const result = await t.one(emailPasswordUserQuery);
await t.query(allRecipeUsersQuery);
await t.query(userToTenantQuery);
return result;
})
.then(r => EmailPasswordUserModel.parse(r));
}
async createEmailPasswordResetToken(args: {
user: EmailPasswordUser;
token: string;
expiresAt: number;
}) {
const deletePendingRequestsQuery = sql`
DELETE
FROM "supertokens_emailpassword_pswd_reset_tokens"
WHERE
"app_id" = 'public'
AND "user_id" =${args.user.userId}
`;
const query = sql`
INSERT INTO "supertokens_emailpassword_pswd_reset_tokens" (
"app_id"
, "user_id"
, "token"
, "email"
, "token_expiry"
) VALUES (
'public'
, ${args.user.userId}
, ${args.token}
, ${args.user.email}
, ${args.expiresAt}
)
RETURNING
"user_id" AS "userId"
, "token"
, "email"
, "token_expiry" AS "tokenExpiry"
`;
return await this.pool.transaction(async t => {
await t.query(deletePendingRequestsQuery);
return await t.one(query).then(EmailPasswordResetTokenModel.parse);
});
}
async updateEmailPasswordBasedOnResetToken(args: { token: string; newPasswordHash: string }) {
const emailPasswordResetTokenQuery = sql`
DELETE
FROM
"supertokens_emailpassword_pswd_reset_tokens"
WHERE
"token" = ${args.token}
RETURNING
"user_id" AS "userId"
, "token"
, "email"
, "token_expiry" AS "tokenExpiry"
`;
const updatePasswordHash = (userId: string) => sql`
UPDATE "supertokens_emailpassword_users"
SET
"password_hash" = ${args.newPasswordHash}
WHERE
"app_id" = 'public'
AND "user_id" = ${userId}
RETURNING
"user_id" AS "userId"
, "email" AS "email"
, "password_hash" AS "passwordHash"
, "time_joined" AS "timeJoined"
`;
return await this.pool.transaction(async t => {
const resetToken = await t
.maybeOne(emailPasswordResetTokenQuery)
.then(EmailPasswordResetTokenModel.parse);
if (!resetToken) {
return null;
}
return await t.one(updatePasswordHash(resetToken.userId)).then(EmailPasswordUserModel.parse);
});
}
}

View file

@ -0,0 +1,63 @@
import { Inject, Injectable, Scope } from 'graphql-modules';
import { type Redis } from 'ioredis';
import { type FastifyRequest } from '@hive/service-common';
import { Logger } from './logger';
import { REDIS_INSTANCE } from './redis';
import { RateLimitConfig } from './tokens';
@Injectable({
scope: Scope.Singleton,
})
export class RedisRateLimiter {
logger: Logger;
constructor(
@Inject(REDIS_INSTANCE) private redis: Redis,
private config: RateLimitConfig,
logger: Logger,
) {
this.logger = logger.child({ module: 'RateLimiter' });
}
/**
* Rate limit Fastify request based on the route definition path.
*/
async isFastifyRouteRateLimited(
req: FastifyRequest,
/** duration of the time window */
timeWindowSeconds = 5 * 60,
/** maximum amount of requests allowed in the time window */
maxActionsPerTimeWindow = 10,
) {
if (!this.config.config) {
this.logger.debug('rate limiting is disabled');
return false;
}
req.routeOptions.url;
let ip = req.ip;
if (this.config.config.ipHeaderName && req.headers[this.config.config.ipHeaderName]) {
this.logger.debug(
'rate limit based on forwarded ip header %s',
this.config.config.ipHeaderName,
);
ip = req.headers[this.config.config.ipHeaderName] as string;
}
const key = `server-rate-limiter:${req.routeOptions.url}:${ip}`;
const current = await this.redis.incr(key);
if (current === 1) {
await this.redis.expire(key, timeWindowSeconds);
}
if (current > maxActionsPerTimeWindow) {
this.logger.debug('request is rate limited');
return true;
}
this.logger.debug('request is not rate limited');
return false;
}
}

View file

@ -84,3 +84,9 @@ AUTH_OKTA_CLIENT_SECRET="<sync>"
OPENTELEMETRY_COLLECTOR_ENDPOINT="<sync>"
HIVE_ENCRYPTION_SECRET=wowverysecuremuchsecret
## Use supertokens at home
SUPERTOKENS_AT_HOME=0
SUPERTOKENS_REFRESH_TOKEN_KEY=1000:15e5968d52a9a48921c1c63d88145441a8099b4a44248809a5e1e733411b3eeb80d87a6e10d3390468c222f6a91fef3427f8afc8b91ea1820ab10c7dfd54a268:39f72164821e08edd6ace99f3bd4e387f45fa4221fe3cd80ecfee614850bc5d647ac2fddc14462a00647fff78c22e8d01bc306a91294f5b889a90ba891bf0aa0
SUPERTOKENS_ACCESS_TOKEN_KEY=s-aaa5da0d-8678-46ef-b56d-9cd19e1cdb5b|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

View file

@ -3,6 +3,9 @@
"type": "module",
"license": "MIT",
"private": true,
"exports": {
"./supertokens-at-home/shared": "./src/supertokens-at-home/shared.ts"
},
"scripts": {
"build": "tsx ../../../scripts/runify.ts src/index.ts src/persisted-documents-worker.ts && tsx ./scripts/copy-persisted-operations.mts",
"dev": "tsup-node --config ../../../configs/tsup/dev.config.node.ts src/dev.ts src/persisted-documents-worker.ts",
@ -18,6 +21,7 @@
"@escape.tech/graphql-armor-max-depth": "2.4.2",
"@escape.tech/graphql-armor-max-directives": "2.3.1",
"@escape.tech/graphql-armor-max-tokens": "2.5.1",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "11.2.0",
"@fastify/formbody": "8.0.2",
"@graphql-hive/plugin-opentelemetry": "1.3.0",
@ -44,6 +48,7 @@
"graphql-yoga": "5.13.3",
"hyperid": "3.3.0",
"ioredis": "5.8.2",
"openid-client": "6.8.2",
"pino-pretty": "11.3.0",
"prom-client": "15.1.3",
"reflect-metadata": "0.2.2",

View file

@ -99,12 +99,22 @@ const RedisModel = zod.object({
REDIS_TLS_ENABLED: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
});
const SuperTokensModel = zod.object({
SUPERTOKENS_CONNECTION_URI: zod.string().url(),
SUPERTOKENS_API_KEY: zod.string(),
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
});
const SuperTokensModel = zod.union([
zod.object({
SUPERTOKENS_AT_HOME: emptyString(zod.literal('0').optional()),
SUPERTOKENS_CONNECTION_URI: zod.string().url(),
SUPERTOKENS_API_KEY: zod.string(),
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
}),
zod.object({
SUPERTOKENS_AT_HOME: zod.literal('1'),
SUPERTOKENS_REFRESH_TOKEN_KEY: zod.string(),
SUPERTOKENS_ACCESS_TOKEN_KEY: zod.string(),
SUPERTOKENS_RATE_LIMIT: emptyString(zod.union([zod.literal('1'), zod.literal('0')]).optional()),
SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME: emptyString(zod.string().optional()),
}),
]);
const GitHubModel = zod.union([
zod.object({
@ -431,16 +441,34 @@ export const env = {
password: redis.REDIS_PASSWORD ?? '',
tlsEnabled: redis.REDIS_TLS_ENABLED === '1',
},
supertokens: {
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
apiKey: supertokens.SUPERTOKENS_API_KEY,
rateLimit:
supertokens.SUPERTOKENS_RATE_LIMIT === '0'
? null
: {
ipHeaderName: supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
supertokens:
supertokens.SUPERTOKENS_AT_HOME === '1'
? {
type: 'atHome' as const,
secrets: {
refreshTokenKey: supertokens.SUPERTOKENS_REFRESH_TOKEN_KEY,
accessTokenKey: supertokens.SUPERTOKENS_ACCESS_TOKEN_KEY,
},
},
rateLimit:
supertokens.SUPERTOKENS_RATE_LIMIT === '0'
? null
: {
ipHeaderName:
supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
},
}
: {
type: 'core' as const,
connectionURI: supertokens.SUPERTOKENS_CONNECTION_URI,
apiKey: supertokens.SUPERTOKENS_API_KEY,
rateLimit:
supertokens.SUPERTOKENS_RATE_LIMIT === '0'
? null
: {
ipHeaderName:
supertokens.SUPERTOKENS_RATE_LIMIT_IP_HEADER_NAME ?? 'CF-Connecting-IP',
},
},
auth: {
github:
authGithub.AUTH_GITHUB === '1'

View file

@ -36,10 +36,6 @@ export interface GraphQLHandlerOptions {
registry: Registry;
signature: string;
tracing?: TracingInstance;
supertokens: {
connectionUri: string;
apiKey: string;
};
isProduction: boolean;
hiveUsageConfig: HiveUsageConfig;
hivePersistedDocumentsConfig: HivePersistedDocumentsConfig;

View file

@ -21,9 +21,12 @@ import {
OrganizationMemberRoles,
OrganizationMembers,
} from '@hive/api';
import { AccessTokenKeyContainer } from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
import { EmailVerification } from '@hive/api/modules/auth/providers/email-verification';
import { OAuthCache } from '@hive/api/modules/auth/providers/oauth-cache';
import { HivePubSub } from '@hive/api/modules/shared/providers/pub-sub';
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
import { RedisRateLimiter } from '@hive/api/modules/shared/providers/redis-rate-limiter';
import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache';
import { TargetsBySlugCache } from '@hive/api/modules/target/providers/targets-by-slug-cache';
import { createArtifactRequestHandler } from '@hive/cdn-script/artifact-handler';
@ -59,6 +62,7 @@ import { clickHouseElapsedDuration, clickHouseReadDuration } from './metrics';
import { createOtelAuthEndpoint } from './otel-auth-endpoint';
import { createPublicGraphQLHandler } from './public-graphql-handler';
import { initSupertokens, oidcIdLookup } from './supertokens';
import { registerSupertokensAtHome } from './supertokens-at-home';
class CorsError extends Error {
constructor() {
@ -128,10 +132,14 @@ export async function main() {
return res.status(403).send(err.message);
}
// We can not upgrade Supertokens Node as it removed some APIs we rely on for
// our SSO flow. This the as `any` cast here.
// The code is still compatible and purely a type error.
return supertokensErrorHandler()(err, req, res as any);
if (env.supertokens.type === 'core') {
// We can not upgrade Supertokens Node as it removed some APIs we rely on for
// our SSO flow. This the as `any` cast here.
// The code is still compatible and purely a type error.
return supertokensErrorHandler()(err, req, res as any);
}
server.log.error(err);
return res.status(500);
});
await server.register(cors, (_: unknown): FastifyCorsOptionsDelegateCallback => {
return (req, callback) => {
@ -159,7 +167,9 @@ export async function main() {
'graphql-client-name',
'ignore-session',
'x-request-id',
...supertokens.getAllCORSHeaders(),
...(env.supertokens.type === 'atHome'
? ['rid', 'fdi-version', 'anti-csrf', 'authorization', 'st-auth-mode']
: supertokens.getAllCORSHeaders()),
],
});
};
@ -369,10 +379,6 @@ export async function main() {
graphiqlEndpoint: graphqlPath,
registry,
signature,
supertokens: {
connectionUri: env.supertokens.connectionURI,
apiKey: env.supertokens.apiKey,
},
isProduction: env.environment !== 'development',
release: env.release,
hiveUsageConfig: env.hive,
@ -393,6 +399,10 @@ export async function main() {
emailVerification: env.auth.requireEmailVerification
? registry.injector.get(EmailVerification)
: null,
accessTokenKey:
env.supertokens.type === 'atHome'
? new AccessTokenKeyContainer(env.supertokens.secrets.accessTokenKey)
: null,
}),
organizationAccessTokenStrategy,
(logger: Logger) =>
@ -443,22 +453,28 @@ export async function main() {
const crypto = new CryptoProvider(env.encryptionSecret);
initSupertokens({
storage,
crypto,
logger: server.log,
redis,
taskScheduler,
broadcastLog(id, message) {
pubSub.publish('oidcIntegrationLogs', id, {
timestamp: new Date().toISOString(),
message,
});
},
});
function broadcastLog(oidcId: string, message: string) {
pubSub.publish('oidcIntegrationLogs', oidcId, {
timestamp: new Date().toISOString(),
message,
});
}
if (env.supertokens.type == 'core') {
initSupertokens({
storage,
crypto,
logger: server.log,
redis,
taskScheduler,
broadcastLog,
});
}
await server.register(formDataPlugin);
await server.register(supertokensFastifyPlugin);
if (env.supertokens.type == 'core') {
await server.register(supertokensFastifyPlugin);
}
await registerTRPC(server, {
router: internalApiRouter,
@ -567,6 +583,19 @@ export async function main() {
return;
});
if (env.supertokens.type === 'atHome') {
await registerSupertokensAtHome(
server,
storage,
registry.injector.get(TaskScheduler),
registry.injector.get(CryptoProvider),
registry.injector.get(RedisRateLimiter),
registry.injector.get(OAuthCache),
broadcastLog,
env.supertokens.secrets,
);
}
if (env.cdn.providers.api !== null) {
const s3 = {
client: new AwsClient({

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,145 @@
# Supertokens Implementation Notes
This file contains some useful information regarding the supertokens implementation.
## OIDC/Social Provider Login Flow
```mermaid
sequenceDiagram
participant Browser
participant Frontend App
participant Backend Server
participant OIDC Provider
Browser->>Frontend App: 1. User clicks "Login with Google/GitHub/etc."
Frontend App->>Backend Server: 2. Requests the provider's authorization URL (e.g., GET /auth/oidc/authorization-url)
note right of Backend Server: The backend generates and stores a<br/>`state` value and a `pkce_verifier`<br/> for security. The `code_challenge`<br/>(derived from the verifier) is part of the URL.
Backend Server-->>Frontend App: 3. Responds with the unique authorization URL
Frontend App->>Browser: 4. Redirects the browser to the OIDC Provider's URL
Browser->>OIDC Provider: 5. User logs in with their credentials and grants consent to your application
OIDC Provider->>Browser: 6. Redirects back to your app's callback URL with an `authorization_code` and the `state` value
Browser->>Frontend App: 7. The Frontend App loads, capturing the `code` and `state` from the URL parameters
Frontend App->>Backend Server: 8. Sends the `authorization_code` and `state` to the backend callback endpoint (e.g., POST /auth/oidc/callback)
note right of Backend Server: The backend first validates that the received `state`<br/>matches the one it stored earlier to prevent CSRF attacks.
Backend Server->>OIDC Provider: 9. Exchanges the `code` for tokens (includes `code`, `client_secret`, and the original `pkce_verifier`)
OIDC Provider-->>Backend Server: 10. Verifies the request and returns an `access_token` and `id_token`
Backend Server->>OIDC Provider: 11. Uses the `access_token` to request the user's identity from a `/userinfo` endpoint
OIDC Provider-->>Backend Server: 12. Returns the user's profile information (e.g., email, name, ID)
note right of Backend Server: The backend now has trusted information about the user.
Backend Server->>Backend Server: 13. Finds an existing user or creates a new one in its database and creates a new session
Backend Server-->>Frontend App: 14. Responds with success and attaches the session cookies (`sAccessToken`, `sRefreshToken`) to the response
Frontend App->>Browser: 15. The login is complete. The browser now stores the session cookies, and the user is authenticated within your application.
```
## Refresh Session Flow
```mermaid
graph TD
subgraph Session Refresh Flow
A[POST /auth/session/refresh] --> B{Extract Refresh Token from Cookie};
B --> E{Verify Refresh Token Signature & Expiry};
E -- Invalid --> F[Clear Cookies & 401 Unauthorized];
E -- Valid --> G{Extract Session Handle from Token};
G --> H{Fetch Session from Store};
H -- Not Found --> F;
H -- Found --> I(Validate Session);
end
subgraph Validate Session Sub-Flow
I --> I1{Is Provided Refresh Token the LATEST one for this session?};
I1 -- No (Old token was used) --> I2[TOKEN THEFT DETECTED];
I2 --> I3[Revoke all sessions for this user & Clear Cookies];
I1 -- Yes --> J{Generate New Access Token};
end
subgraph Token Generation and Response
J --> K{"Generate New Refresh Token (Token Rotation)"};
K --> L{Update Session in Store with new token hash};
L --> M{Set New Tokens in Cookies};
M --> N[200 OK];
end
```
### Refresh Session Security
This section goes a bit into detail on how token theft is prevented.
The mechanism relies on a "chained" or "linked" token system, where each new refresh token is
cryptographically linked to the one that came before it. The server only needs to store a single
hash to validate the entire chain.
#### The Key Components
1. **The Refresh Token Payload**: When a new refresh token is created, it contains a special field
called `parentRefreshTokenHash1`. This field holds a SHA256 hash of the _previous_ refresh token
that was used. The very first refresh token in a session will not have this field.
2. **The Session Store (Database)**: The server does not store the raw refresh tokens. Instead, for
each session, it stores a value called `refreshTokenHash2`. This is the hash of the **latest and
currently valid** refresh token that has been issued to the client.
#### The Validation Process
When a client sends a request to `/auth/session/refresh`, the server performs the following checks,
which you can see in the `supertokens-at-home.ts` file around lines 499-509:
```ts
if (
!payload.parentRefreshTokenHash1 &&
sha256(sha256(refreshToken)) !== session.refreshTokenHash2
) {
req.log.debug('The refreshTokenHash2 does not match (first refresh).');
return unsetAuthCookies(rep).status(404).send();
}
if (
payload.parentRefreshTokenHash1 &&
session.refreshTokenHash2 !== sha256(payload.parentRefreshTokenHash1)
) {
```
##### Scenario 1: The Very First Refresh
- **Condition**: The incoming refresh token's payload **does not** contain a
`parentRefreshTokenHash1`.
- **Action**: The server knows this must be the original refresh token created at login. To verify
it, it performs a double SHA256 hash on the raw token (`sha256(sha256(refreshToken))`) and
compares it to the `refreshTokenHash2` stored in the session.
- **Result**: If they match, the token is valid. If not, the request is rejected.
##### Scenario 2: All Subsequent Refreshes
- **Condition**: The incoming refresh token's payload **does** contain a `parentRefreshTokenHash1`.
- **Action**: This is the crucial step for detecting token reuse. The server takes the
`parentRefreshTokenHash1` from the payload, hashes it once
(`sha256(payload.parentRefreshTokenHash1)`), and compares it to the `refreshTokenHash2` from the
session store.
- **Result**:
- **Match**: This proves that the token used to generate the _current_ token was the latest one
known to the server. The session is valid.
- **Mismatch**: This is the **token theft** scenario. It means that the `refreshTokenHash2` in the
database is newer than the one being presented. This can only happen if an older, already used
token has been submitted. The request is immediately rejected.
### The "Rotation": Updating the Hash
If the validation is successful, the server generates a _new_ refresh token and then immediately
updates the session in the database. The `refreshTokenHash2` is replaced with a new hash derived
from the refresh token that was just used.
This ensures that for the next refresh cycle, only the newly issued refresh token will be considered
valid, continuing the chain and maintaining security.

View file

@ -0,0 +1,115 @@
import { z } from 'zod';
import type { User } from '@hive/api';
import {
AccessTokenKeyContainer,
createAccessToken,
createRefreshToken,
sha256,
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
export const SuperTokensSessionPayloadV2Model = z.object({
version: z.literal('2'),
superTokensUserId: z.string(),
email: z.string(),
userId: z.string(),
oidcIntegrationId: z.string().nullable(),
});
export type SuperTokensSessionPayload = z.TypeOf<typeof SuperTokensSessionPayloadV2Model>;
const PasswordModel = z
.string()
.min(10, { message: 'Password must be at least 10 characters long.' })
// Check 2: At least one uppercase letter
.regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter.' })
// Check 3: At least one special character
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
message: 'Password must contain at least one special character.',
})
// Check 4: At least one digit
.regex(/[0-9]/, { message: 'Password must contain at least one digit.' })
// Check 5: At least one lowercase letter
.regex(/[a-z]/, { message: 'Password must contain at least one lowercase letter.' });
export function validatePassword(password: string):
| {
status: 'OK';
}
| {
status: 'INVALID';
message: string;
} {
const result = PasswordModel.safeParse(password);
if (!result.success) {
return {
status: 'INVALID',
message: result.error.message,
};
}
return { status: 'OK' };
}
export async function createNewSession(
supertokensStore: SuperTokensStore,
args: {
superTokensUserId: string;
hiveUser: User;
oidcIntegrationId: string | null;
},
secrets: {
refreshTokenKey: string;
accessTokenKey: AccessTokenKeyContainer;
},
) {
const sessionHandle = crypto.randomUUID();
// 1 week for now
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1_000;
const refreshToken = createRefreshToken(
{
sessionHandle,
userId: args.superTokensUserId,
parentRefreshTokenHash1: null,
},
secrets.refreshTokenKey,
);
const payload: SuperTokensSessionPayload = {
version: '2',
superTokensUserId: args.superTokensUserId,
userId: args.hiveUser.id,
oidcIntegrationId: args.oidcIntegrationId ?? null,
email: args.hiveUser.email,
};
const stringifiedPayload = JSON.stringify(payload);
const session = await supertokensStore.createSession(
sessionHandle,
args.superTokensUserId,
stringifiedPayload,
stringifiedPayload,
sha256(sha256(refreshToken)),
expiresAt,
);
const accessToken = createAccessToken(
{
sub: args.superTokensUserId,
sessionHandle,
sessionData: payload,
refreshTokenHash1: sha256(refreshToken),
parentRefreshTokenHash1: null,
},
secrets.accessTokenKey,
);
return {
session,
refreshToken,
accessToken,
};
}

View file

@ -101,7 +101,7 @@ export const backendConfig = (requirements: {
return {
framework: 'fastify',
supertokens: {
connectionURI: env.supertokens.connectionURI,
connectionURI: env.supertokens.connectionURI ?? '',
apiKey: env.supertokens.apiKey,
},
telemetry: false,

View file

@ -79,7 +79,7 @@ export const frontendConfig = () => {
},
override: env.auth.oidc ? getOIDCOverrides() : undefined,
}),
SessionReact.init(),
SessionReact.init({}),
],
};
};

View file

@ -10,23 +10,33 @@ export const createThirdPartyEmailPasswordReactOIDCProvider = () => ({
const delimiter = '--';
let currentAuthUrl: null | string = null;
export const getOIDCOverrides = (): UserInput['override'] => ({
functions: originalImplementation => ({
...originalImplementation,
generateStateToSendToOAuthProvider(input) {
const hash = originalImplementation.generateStateToSendToOAuthProvider(input);
let state: null | string = null;
if (currentAuthUrl) {
const url = new URL(currentAuthUrl);
state = url.searchParams.get('state');
}
state ||= originalImplementation.generateStateToSendToOAuthProvider(input);
const oidcId = input?.userContext?.['oidcId'];
if (typeof oidcId === 'string') {
return `${hash}${delimiter}${oidcId}`;
return `${state}${delimiter}${oidcId}`;
}
return hash;
return state;
},
getAuthorisationURLFromBackend(input) {
async getAuthorisationURLFromBackend(input) {
const maybeId: unknown = input.userContext['oidcId'];
return originalImplementation.getAuthorisationURLFromBackend(
const result = await originalImplementation.getAuthorisationURLFromBackend(
typeof maybeId === 'string'
? {
...input,
@ -43,6 +53,9 @@ export const getOIDCOverrides = (): UserInput['override'] => ({
}
: input,
);
currentAuthUrl = result.urlWithQueryParams;
return result;
},
thirdPartySignInAndUp(input) {
const locationUrl = new URL(window.location.toString());

View file

@ -3,9 +3,7 @@ import { env } from '@/env/frontend';
type Provider = 'google' | 'okta' | 'github' | 'oidc';
const providers: Provider[] = ['google', 'okta', 'github', 'oidc'];
export const enabledProviders: Provider[] = providers.filter(
provider => env.auth[provider] === true,
);
export const enabledProviders: Provider[] = providers.filter(provider => !!env.auth[provider]);
export function isProviderEnabled(provider: Provider) {
return enabledProviders.includes(provider);

View file

@ -292,3 +292,6 @@ type GraphQLPayload = {
documentId: string;
}
);
// @ts-expect-error for testing purposes ok
window.__YOU_ARE_FIRED_attemptSessionRefresh = () => Session.attemptRefreshingSession();

View file

@ -23,6 +23,7 @@ import { exhaustiveGuard } from '@/lib/utils';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { Link, Navigate } from '@tanstack/react-router';
import { PasswordStringModel } from './auth-sign-up';
const ResetPasswordFormSchema = z.object({
email: z
@ -194,7 +195,7 @@ function AuthResetPasswordEmail(props: { email: string | null; redirectToPath: s
}
const NewPasswordFormSchema = z.object({
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
newPassword: PasswordStringModel,
});
type NewPasswordFormValues = z.infer<typeof NewPasswordFormSchema>;

View file

@ -73,7 +73,7 @@ const SignInFormSchema = z.object({
required_error: 'Email is required',
})
.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
password: z.string(),
});
type SignInFormValues = z.infer<typeof SignInFormSchema>;

View file

@ -36,6 +36,22 @@ import { useMutation } from '@tanstack/react-query';
import { Link, Navigate, useRouter } from '@tanstack/react-router';
import { SignInButton } from './auth-sign-in';
export const PasswordStringModel = z
.string({
required_error: 'Password is required',
})
.min(10, { message: 'Password must be at least 10 characters long.' })
// Check 2: At least one uppercase letter
.regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter.' })
// Check 3: At least one special character
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
message: 'Password must contain at least one special character.',
})
// Check 4: At least one digit
.regex(/[0-9]/, { message: 'Password must contain at least one digit.' })
// Check 5: At least one lowercase letter
.regex(/[a-z]/, { message: 'Password must contain at least one lowercase letter.' });
const SignUpFormSchema = z.object({
firstName: z.string({
required_error: 'First name is required',
@ -48,7 +64,7 @@ const SignUpFormSchema = z.object({
required_error: 'Email is required',
})
.email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
password: PasswordStringModel,
});
type SignUpFormValues = z.infer<typeof SignUpFormSchema>;

View file

@ -877,6 +877,9 @@ importers:
ioredis-mock:
specifier: 8.9.0
version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.8.2)
jsonwebtoken:
specifier: 9.0.3
version: 9.0.3
lodash:
specifier: ^4.17.23
version: 4.17.23
@ -1232,6 +1235,9 @@ importers:
'@escape.tech/graphql-armor-max-tokens':
specifier: 2.5.1
version: 2.5.1
'@fastify/cookie':
specifier: 11.0.2
version: 11.0.2
'@fastify/cors':
specifier: 11.2.0
version: 11.2.0
@ -1310,6 +1316,9 @@ importers:
ioredis:
specifier: 5.8.2
version: 5.8.2
openid-client:
specifier: 6.8.2
version: 6.8.2
pino-pretty:
specifier: 11.3.0
version: 11.3.0
@ -1346,7 +1355,7 @@ importers:
version: 1.0.9(pino@10.3.0)
'@graphql-hive/plugin-opentelemetry':
specifier: 1.3.0
version: 1.3.0(encoding@0.1.13)(graphql@16.12.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)
version: 1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)
'@opentelemetry/api':
specifier: 1.9.0
version: 1.9.0
@ -3904,6 +3913,9 @@ packages:
'@fastify/busboy@3.1.1':
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
'@fastify/cookie@11.0.2':
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
'@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
@ -14040,6 +14052,9 @@ packages:
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@ -14175,8 +14190,8 @@ packages:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jsox@1.2.119:
@ -14205,15 +14220,15 @@ packages:
just-diff@6.0.2:
resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jwks-rsa@3.2.0:
resolution: {integrity: sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==}
engines: {node: '>=14'}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
kafkajs@2.2.4:
resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==}
@ -15583,6 +15598,9 @@ packages:
nullthrows@1.1.1:
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
oauth4webapi@3.8.5:
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -15692,6 +15710,9 @@ packages:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
openid-client@6.8.2:
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
opentelemetry-instrumentation-fetch-node@1.2.3:
resolution: {integrity: sha512-Qb11T7KvoCevMaSeuamcLsAD+pZnavkhDnlVL0kRozfhl42dKG5Q3anUklAFKJZjY3twLR+BnRa6DlwwkIE/+A==}
engines: {node: '>18.0.0'}
@ -22144,6 +22165,11 @@ snapshots:
'@fastify/busboy@3.1.1': {}
'@fastify/cookie@11.0.2':
dependencies:
cookie: 1.1.1
fastify-plugin: 5.1.0
'@fastify/cors@11.2.0':
dependencies:
fastify-plugin: 5.1.0
@ -22726,6 +22752,56 @@ snapshots:
- winston
- ws
'@graphql-hive/gateway-runtime@2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)':
dependencies:
'@envelop/core': 5.5.1
'@envelop/disable-introspection': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)
'@envelop/generic-auth': 11.0.0(@envelop/core@5.5.1)(graphql@16.12.0)
'@envelop/instrumentation': 1.0.0
'@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0)
'@graphql-hive/logger': 1.0.9(pino@10.3.0)
'@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2)
'@graphql-hive/signal': 2.0.0
'@graphql-hive/yoga': 0.46.0(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)(pino@10.3.0)
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/fusion-runtime': 1.6.2(@types/node@25.0.2)(graphql@16.12.0)(pino@10.3.0)
'@graphql-mesh/hmac-upstream-signature': 2.0.8(graphql@16.12.0)
'@graphql-mesh/plugin-response-cache': 0.104.18(graphql@16.12.0)
'@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-mesh/utils': 0.104.16(graphql@16.12.0)
'@graphql-tools/batch-delegate': 10.0.8(graphql@16.12.0)
'@graphql-tools/delegate': 12.0.2(graphql@16.12.0)
'@graphql-tools/executor-common': 1.0.5(graphql@16.12.0)
'@graphql-tools/executor-http': 3.0.7(@types/node@25.0.2)(graphql@16.12.0)
'@graphql-tools/federation': 4.2.6(@types/node@25.0.2)(graphql@16.12.0)
'@graphql-tools/stitch': 10.1.6(graphql@16.12.0)
'@graphql-tools/utils': 10.11.0(graphql@16.12.0)
'@graphql-tools/wrap': 11.1.2(graphql@16.12.0)
'@graphql-yoga/plugin-apollo-usage-report': 0.12.1(@envelop/core@5.5.1)(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)
'@graphql-yoga/plugin-csrf-prevention': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))
'@graphql-yoga/plugin-defer-stream': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)
'@graphql-yoga/plugin-persisted-operations': 3.16.2(graphql-yoga@5.17.1(graphql@16.12.0))(graphql@16.12.0)
'@types/node': 25.0.2
'@whatwg-node/disposablestack': 0.0.6
'@whatwg-node/promise-helpers': 1.3.2
'@whatwg-node/server': 0.10.17
'@whatwg-node/server-plugin-cookies': 1.0.5
graphql: 16.12.0
graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.18.0)
graphql-yoga: 5.17.1(graphql@16.12.0)
tslib: 2.8.1
transitivePeerDependencies:
- '@fastify/websocket'
- '@logtape/logtape'
- '@nats-io/nats-core'
- crossws
- ioredis
- pino
- uWebSockets.js
- winston
- ws
'@graphql-hive/gateway-runtime@2.5.0(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)':
dependencies:
'@envelop/core': 5.5.1
@ -22911,6 +22987,45 @@ snapshots:
- winston
- ws
'@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)':
dependencies:
'@graphql-hive/core': 0.18.0(graphql@16.12.0)(pino@10.3.0)
'@graphql-hive/gateway-runtime': 2.5.0(graphql@16.12.0)(pino@10.3.0)(ws@8.18.0)
'@graphql-hive/logger': 1.0.9(pino@10.3.0)
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-mesh/utils': 0.104.16(graphql@16.12.0)
'@graphql-tools/utils': 10.11.0(graphql@16.12.0)
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
'@opentelemetry/auto-instrumentations-node': 0.67.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)
'@opentelemetry/context-async-hooks': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-trace-otlp-grpc': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/exporter-trace-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.38.0
'@whatwg-node/promise-helpers': 1.3.2
graphql: 16.12.0
tslib: 2.8.1
transitivePeerDependencies:
- '@fastify/websocket'
- '@logtape/logtape'
- '@nats-io/nats-core'
- crossws
- encoding
- ioredis
- pino
- supports-color
- uWebSockets.js
- winston
- ws
'@graphql-hive/plugin-opentelemetry@1.3.0(encoding@0.1.13)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)(ws@8.18.0)':
dependencies:
'@graphql-hive/core': 0.18.0(graphql@16.9.0)(pino@10.3.0)
@ -23391,6 +23506,37 @@ snapshots:
- pino
- winston
'@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.0.2)(graphql@16.12.0)(pino@10.3.0)':
dependencies:
'@envelop/core': 5.5.1
'@envelop/instrumentation': 1.0.0
'@graphql-hive/logger': 1.0.9(pino@10.3.0)
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/transport-common': 1.0.12(graphql@16.12.0)(pino@10.3.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-mesh/utils': 0.104.16(graphql@16.12.0)
'@graphql-tools/batch-execute': 10.0.4(graphql@16.12.0)
'@graphql-tools/delegate': 12.0.2(graphql@16.12.0)
'@graphql-tools/executor': 1.4.13(graphql@16.12.0)
'@graphql-tools/federation': 4.2.6(@types/node@25.0.2)(graphql@16.12.0)
'@graphql-tools/merge': 9.1.5(graphql@16.12.0)
'@graphql-tools/stitch': 10.1.6(graphql@16.12.0)
'@graphql-tools/stitching-directives': 4.0.8(graphql@16.12.0)
'@graphql-tools/utils': 10.11.0(graphql@16.12.0)
'@graphql-tools/wrap': 11.1.2(graphql@16.12.0)
'@whatwg-node/disposablestack': 0.0.6
'@whatwg-node/promise-helpers': 1.3.2
graphql: 16.12.0
graphql-yoga: 5.17.1(graphql@16.12.0)
tslib: 2.8.1
transitivePeerDependencies:
- '@logtape/logtape'
- '@nats-io/nats-core'
- '@types/node'
- ioredis
- pino
- winston
'@graphql-mesh/fusion-runtime@1.6.2(@types/node@25.0.2)(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)':
dependencies:
'@envelop/core': 5.5.1
@ -23422,6 +23568,21 @@ snapshots:
- pino
- winston
'@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)':
dependencies:
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-mesh/utils': 0.104.16(graphql@16.12.0)
'@graphql-tools/executor-common': 1.0.5(graphql@16.12.0)
'@graphql-tools/utils': 10.10.3(graphql@16.12.0)
'@whatwg-node/promise-helpers': 1.3.2
graphql: 16.12.0
json-stable-stringify: 1.3.0
tslib: 2.8.1
transitivePeerDependencies:
- '@nats-io/nats-core'
- ioredis
'@graphql-mesh/hmac-upstream-signature@2.0.8(graphql@16.12.0)(ioredis@5.8.2)':
dependencies:
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
@ -23531,6 +23692,25 @@ snapshots:
- '@nats-io/nats-core'
- ioredis
'@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)':
dependencies:
'@envelop/core': 5.5.1
'@envelop/response-cache': 9.0.0(@envelop/core@5.5.1)(graphql@16.12.0)
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-mesh/utils': 0.104.16(graphql@16.12.0)
'@graphql-tools/utils': 10.9.1(graphql@16.12.0)
'@graphql-yoga/plugin-response-cache': 3.15.4(graphql-yoga@5.16.2(graphql@16.12.0))(graphql@16.12.0)
'@whatwg-node/promise-helpers': 1.3.2
cache-control-parser: 2.0.6
graphql: 16.12.0
graphql-yoga: 5.16.2(graphql@16.12.0)
tslib: 2.8.1
transitivePeerDependencies:
- '@nats-io/nats-core'
- ioredis
'@graphql-mesh/plugin-response-cache@0.104.18(graphql@16.12.0)(ioredis@5.8.2)':
dependencies:
'@envelop/core': 5.5.1
@ -23618,6 +23798,25 @@ snapshots:
- pino
- winston
'@graphql-mesh/transport-common@1.0.12(graphql@16.12.0)(pino@10.3.0)':
dependencies:
'@envelop/core': 5.5.1
'@graphql-hive/logger': 1.0.9(pino@10.3.0)
'@graphql-hive/pubsub': 2.1.1(ioredis@5.8.2)
'@graphql-hive/signal': 2.0.0
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-tools/executor': 1.4.13(graphql@16.12.0)
'@graphql-tools/executor-common': 1.0.5(graphql@16.12.0)
'@graphql-tools/utils': 10.10.3(graphql@16.12.0)
graphql: 16.12.0
tslib: 2.8.1
transitivePeerDependencies:
- '@logtape/logtape'
- '@nats-io/nats-core'
- ioredis
- pino
- winston
'@graphql-mesh/transport-common@1.0.12(graphql@16.9.0)(ioredis@5.8.2)(pino@10.3.0)':
dependencies:
'@envelop/core': 5.5.1
@ -23734,6 +23933,30 @@ snapshots:
- '@nats-io/nats-core'
- ioredis
'@graphql-mesh/utils@0.104.16(graphql@16.12.0)':
dependencies:
'@envelop/instrumentation': 1.0.0
'@graphql-mesh/cross-helpers': 0.4.10(graphql@16.12.0)
'@graphql-mesh/string-interpolation': 0.5.9(graphql@16.12.0)
'@graphql-mesh/types': 0.104.16(graphql@16.12.0)(ioredis@5.8.2)
'@graphql-tools/batch-delegate': 10.0.5(graphql@16.12.0)
'@graphql-tools/delegate': 11.1.3(graphql@16.12.0)
'@graphql-tools/utils': 10.9.1(graphql@16.12.0)
'@graphql-tools/wrap': 11.0.5(graphql@16.12.0)
'@whatwg-node/disposablestack': 0.0.6
'@whatwg-node/fetch': 0.10.13
'@whatwg-node/promise-helpers': 1.3.1
dset: 3.1.4
graphql: 16.12.0
js-yaml: 4.1.1
lodash.get: 4.4.2
lodash.topath: 4.5.2
tiny-lru: 11.4.7
tslib: 2.8.1
transitivePeerDependencies:
- '@nats-io/nats-core'
- ioredis
'@graphql-mesh/utils@0.104.16(graphql@16.12.0)(ioredis@5.8.2)':
dependencies:
'@envelop/instrumentation': 1.0.0
@ -25285,7 +25508,7 @@ snapshots:
'@whatwg-node/server-plugin-cookies': 1.0.5
graphql: 16.12.0
graphql-yoga: 5.16.2(graphql@16.12.0)
jsonwebtoken: 9.0.2
jsonwebtoken: 9.0.3
jwks-rsa: 3.2.0
tslib: 2.8.1
transitivePeerDependencies:
@ -36499,6 +36722,8 @@ snapshots:
jose@4.15.9: {}
jose@6.1.3: {}
joycon@3.1.1: {}
js-beautify@1.14.6:
@ -36633,9 +36858,9 @@ snapshots:
jsonparse@1.3.1: {}
jsonwebtoken@9.0.2:
jsonwebtoken@9.0.3:
dependencies:
jws: 3.2.2
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
@ -36678,7 +36903,7 @@ snapshots:
just-diff@6.0.2: {}
jwa@1.4.2:
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
@ -36695,9 +36920,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
jws@3.2.2:
jws@4.0.1:
dependencies:
jwa: 1.4.2
jwa: 2.0.1
safe-buffer: 5.2.1
kafkajs@2.2.4: {}
@ -38641,6 +38866,8 @@ snapshots:
nullthrows@1.1.1: {}
oauth4webapi@3.8.5: {}
object-assign@4.1.1: {}
object-hash@3.0.0: {}
@ -38784,6 +39011,11 @@ snapshots:
opener@1.5.2: {}
openid-client@6.8.2:
dependencies:
jose: 6.1.3
oauth4webapi: 3.8.5
opentelemetry-instrumentation-fetch-node@1.2.3(@opentelemetry/api@1.9.0):
dependencies:
'@opentelemetry/api': 1.9.0
@ -41695,7 +41927,7 @@ snapshots:
axios: 1.13.5(debug@4.4.1)
dayjs: 1.11.13
https-proxy-agent: 5.0.1
jsonwebtoken: 9.0.2
jsonwebtoken: 9.0.3
qs: 6.14.2
scmp: 2.1.0
url-parse: 1.5.10

View file

@ -41,6 +41,7 @@
],
"@hive/cdn-script/aws": ["./packages/services/cdn-worker/src/aws.ts"],
"@hive/server": ["./packages/services/server/src/api.ts"],
"@hive/server/*": ["./packages/services/server/src/*"],
"@hive/schema": ["./packages/services/schema/src/api.ts"],
"@hive/schema/*": ["./packages/services/schema/src/*"],
"@hive/usage-common": ["./packages/services/usage-common/src/index.ts"],