Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ The API then exposes:
- `GET http://localhost:3000/api/v1/health/live`
- `GET http://localhost:3000/api/v1/health/ready`

Authentication and Google-to-Hive provisioning configuration is documented in
[`apps/api/README.md`](apps/api/README.md). The API intentionally fails closed for durable identity
operations when PostgreSQL or an approved external provider is not configured.

Build the Nakama module with the runtime-compatible toolchain:

```bash
Expand Down
22 changes: 22 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5433/hive_chameleon?sslmode=disable

# Generate independent values with: openssl rand -base64 48 | tr '+/' '-_' | tr -d '='
AUTH_TOKEN_SECRET=
AUTH_IDENTITY_LOOKUP_KEY=
AUTH_TOKEN_ISSUER=hive-chameleon-api
AUTH_TOKEN_AUDIENCE=hive-chameleon-client

HIVE_RPC_URL=https://api.hive.blog/
HIVE_CHAIN_ID=0000000000000000000000000000000000000000000000000000000000000000

# Configure all three Google values together; redirect URIs are an exact comma-separated allowlist.
GOOGLE_OIDC_CLIENT_ID=
GOOGLE_OIDC_CLIENT_SECRET=
GOOGLE_OIDC_REDIRECT_URIS=

# Provider metadata only. Enabling these does not install or approve sponsor/custody adapters.
PROVISIONING_SPONSOR_PROGRAM=
PROVISIONING_SPONSOR_POLICY_VERSION=
PROVISIONING_SPONSOR_HIVE_ACCOUNT=
44 changes: 44 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Product API

The NestJS API now owns direct-Hive and Google game-session establishment, refresh rotation,
logout, restricted Google onboarding, and the scoped Nakama handoff.

## Local configuration

Start the application PostgreSQL stack and apply migrations, then copy `.env.example` to a local
ignored `.env` or export the values through your shell. Production refuses to start without
independent 32-byte `AUTH_TOKEN_SECRET` and `AUTH_IDENTITY_LOOKUP_KEY` values. The first signs
short-lived access/onboarding JWTs and HMACs refresh-token lookups; the second makes the verified
Google issuer/subject lookup deterministic without storing the raw subject or email.

Direct Hive login signs the exact UTF-8 challenge returned by
`POST /api/v1/auth/hive/challenges`. Keychain/HiveAuth integrations submit the resulting canonical
65-byte compact secp256k1 signature as 130 lowercase hexadecimal characters. The backend consumes
the challenge once, recovers the signing key from SHA-256 of the exact challenge bytes, and checks
the account's current posting authority through Hive RPC before creating a player session.

Google uses authorization-code + PKCE exchange through Google's maintained Node library. Redirect
URIs must match the configured allowlist exactly. Only the verified issuer and stable subject are
used for identity; email is never an ownership key. A new subject receives an onboarding token,
not a playable player or ordinary access token.

## Provisioning gate

The account-provisioning coordinator uses four provider-neutral contracts:

- a custody provider generates non-exportable owner, active, posting, and memo keys and returns
only public keys plus opaque references;
- an approved signup sponsor consumes an account-creation token via
`claim_account`/`create_claimed_account` and delegates initial Resource Credits;
- the Hive projection verifies the exact account, authorities, RC evidence, and irreversibility;
- the PostgreSQL repository makes every stage retryable under stable idempotency keys and links a
playable identity only after all verification passes.

No production custody provider or signup sponsor is selected by this repository. The onboarding
API reports `503 provisioning_provider_unavailable` until approved adapters and policy metadata
are configured. This is deliberate fail-closed behavior; Hetzner is the hosting environment, not
a substitute for a custody/key-management provider.

The `CustodialPlayerSigner` accepts only an already-authorized, expiring grant containing an opaque
key reference. It verifies every returned signature against the expected public key and never
accepts a raw private key, arbitrary client-selected key reference, or an AWS-specific object.
10 changes: 7 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,25 @@
"private": true,
"description": "Hive Chameleon NestJS product API",
"scripts": {
"build": "nest build",
"build": "npm run build --workspace @hive-chameleon/database && npm run build --workspace @hive-chameleon/hive-gateway && nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
"typecheck": "npm run build --workspace @hive-chameleon/database && npm run build --workspace @hive-chameleon/hive-gateway && tsc --noEmit"
},
"dependencies": {
"@heroiclabs/nakama-js": "2.8.0",
"@hive-chameleon/database": "^0.1.0",
"@hive-chameleon/hive-gateway": "^0.1.0",
"@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.28",
"@nestjs/platform-express": "11.1.28",
"google-auth-library": "^10.5.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
"rxjs": "7.8.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@nestjs/cli": "11.0.24",
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

import { AuthModule } from './auth/auth.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { ProvisioningModule } from './provisioning/provisioning.module';
import { RealtimeModule } from './realtime/realtime.module';

@Module({
Expand All @@ -11,6 +14,9 @@ import { RealtimeModule } from './realtime/realtime.module';
ignoreEnvFile: process.env.NODE_ENV === 'production',
isGlobal: true,
}),
DatabaseModule,
AuthModule,
ProvisioningModule,
HealthModule,
RealtimeModule,
],
Expand Down
50 changes: 50 additions & 0 deletions apps/api/src/auth/access-session.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
type CanActivate,
type ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';

import { AUTH_REPOSITORY, type AuthHttpRequest, type AuthRepository } from './auth.types';
import { TokenService } from './token.service';

@Injectable()
export class AccessSessionGuard implements CanActivate {
public constructor(
@Inject(TokenService) private readonly tokens: TokenService,
@Inject(AUTH_REPOSITORY) private readonly repository: AuthRepository,
) {}

public async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthHttpRequest>();
const token = bearerToken(request.headers.authorization);
const claims = this.tokens.verifyAccessToken(token, new Date());
const session = await this.repository.findActiveSession(claims.sessionId, new Date());
if (!session || session.playerId !== claims.playerId) {
throw invalidSession();
}
request.authPrincipal = session;
return true;
}
}

export function bearerToken(header: string | string[] | undefined): string {
if (typeof header !== 'string' || !header.startsWith('Bearer ')) {
throw invalidSession();
}
const token = header.slice('Bearer '.length);
if (!token || token.includes(' ')) {
throw invalidSession();
}
return token;
}

function invalidSession(): UnauthorizedException {
return new UnauthorizedException({
code: 'invalid_session',
detail: 'The session credential is missing, invalid, expired, or revoked.',
status: 401,
title: 'Unauthorized',
});
}
43 changes: 43 additions & 0 deletions apps/api/src/auth/auth.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';

import { loadAuthConfig } from './auth.config';

const secret = Buffer.alloc(32, 7).toString('base64url');

describe('loadAuthConfig', () => {
it('requires independent production token and identity lookup secrets', () => {
expect(() => loadAuthConfig({ NODE_ENV: 'production' })).toThrow(/AUTH_TOKEN_SECRET/);
expect(() => loadAuthConfig({ AUTH_TOKEN_SECRET: secret, NODE_ENV: 'production' })).toThrow(
/AUTH_IDENTITY_LOOKUP_KEY/,
);
});

it('accepts a complete Google OIDC configuration and exact redirect allowlist', () => {
const config = loadAuthConfig({
AUTH_IDENTITY_LOOKUP_KEY: secret,
AUTH_TOKEN_SECRET: secret,
GOOGLE_OIDC_CLIENT_ID: 'client-id',
GOOGLE_OIDC_CLIENT_SECRET: 'client-secret',
GOOGLE_OIDC_REDIRECT_URIS: 'https://play.example.com/oidc/callback',
NODE_ENV: 'production',
});

expect(config.google?.redirectUris.has('https://play.example.com/oidc/callback')).toBe(true);
});

it('rejects partial Google configuration and non-TLS production redirects', () => {
expect(() => loadAuthConfig({ GOOGLE_OIDC_CLIENT_ID: 'client-id' })).toThrow(
/requires .* together/,
);
expect(() =>
loadAuthConfig({
AUTH_IDENTITY_LOOKUP_KEY: secret,
AUTH_TOKEN_SECRET: secret,
GOOGLE_OIDC_CLIENT_ID: 'client-id',
GOOGLE_OIDC_CLIENT_SECRET: 'client-secret',
GOOGLE_OIDC_REDIRECT_URIS: 'http://play.example.com/callback',
NODE_ENV: 'production',
}),
).toThrow(/HTTPS/);
});
});
165 changes: 165 additions & 0 deletions apps/api/src/auth/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { randomBytes } from 'node:crypto';

export interface GoogleOidcConfig {
readonly clientId: string;
readonly clientSecret: string;
readonly redirectUris: ReadonlySet<string>;
}

export interface AuthConfig {
readonly accessTokenTtlSeconds: number;
readonly audience: string;
readonly challengeTtlSeconds: number;
readonly google: GoogleOidcConfig | null;
readonly hiveChainId: string;
readonly hiveRpcUrl: string;
readonly identityLookupKey: Buffer;
readonly issuer: string;
readonly refreshTokenTtlSeconds: number;
readonly tokenKey: Buffer;
}

export function loadAuthConfig(environment: NodeJS.ProcessEnv = process.env): AuthConfig {
const production = environment.NODE_ENV === 'production';
const tokenKey = readSecret(environment.AUTH_TOKEN_SECRET, 'AUTH_TOKEN_SECRET', production);
const identityLookupKey = environment.AUTH_IDENTITY_LOOKUP_KEY
? decodeSecret(environment.AUTH_IDENTITY_LOOKUP_KEY, 'AUTH_IDENTITY_LOOKUP_KEY')
: production
? missingSecret('AUTH_IDENTITY_LOOKUP_KEY')
: tokenKey;

const hiveRpcUrl = environment.HIVE_RPC_URL?.trim() || 'https://api.hive.blog/';
assertHttpsEndpoint(hiveRpcUrl, 'HIVE_RPC_URL');
const hiveChainId = environment.HIVE_CHAIN_ID?.trim() || '0'.repeat(64);
if (!/^[0-9a-f]{64}$/.test(hiveChainId)) {
throw new Error('HIVE_CHAIN_ID must be 32 lowercase hexadecimal bytes.');
}

return {
accessTokenTtlSeconds: readInteger(environment.AUTH_ACCESS_TTL_SECONDS, 900, 60, 3_600),
audience: environment.AUTH_TOKEN_AUDIENCE?.trim() || 'hive-chameleon-client',
challengeTtlSeconds: readInteger(environment.AUTH_CHALLENGE_TTL_SECONDS, 300, 60, 900),
google: readGoogleConfig(environment, production),
hiveChainId,
hiveRpcUrl,
identityLookupKey,
issuer: environment.AUTH_TOKEN_ISSUER?.trim() || 'hive-chameleon-api',
refreshTokenTtlSeconds: readInteger(
environment.AUTH_REFRESH_TTL_SECONDS,
30 * 24 * 60 * 60,
3_600,
90 * 24 * 60 * 60,
),
tokenKey,
};
}

function readGoogleConfig(
environment: NodeJS.ProcessEnv,
production: boolean,
): GoogleOidcConfig | null {
const clientId = environment.GOOGLE_OIDC_CLIENT_ID?.trim();
const clientSecret = environment.GOOGLE_OIDC_CLIENT_SECRET?.trim();
const redirectValues = (environment.GOOGLE_OIDC_REDIRECT_URIS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (!clientId && !clientSecret && redirectValues.length === 0) {
return null;
}
if (!clientId || !clientSecret || redirectValues.length === 0) {
throw new Error(
'Google OIDC requires GOOGLE_OIDC_CLIENT_ID, GOOGLE_OIDC_CLIENT_SECRET, and GOOGLE_OIDC_REDIRECT_URIS together.',
);
}
const redirectUris = new Set(
redirectValues.map((value) => canonicalRedirectUri(value, production)),
);
return { clientId, clientSecret, redirectUris };
}

function canonicalRedirectUri(value: string, production: boolean): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error('GOOGLE_OIDC_REDIRECT_URIS must contain absolute HTTP or HTTPS URLs.');
}
const localDevelopment =
!production &&
url.protocol === 'http:' &&
(url.hostname === '127.0.0.1' || url.hostname === 'localhost');
if (
(!localDevelopment && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== '' ||
url.hash !== ''
) {
throw new Error(
'GOOGLE_OIDC_REDIRECT_URIS must use HTTPS (except local development) and contain no credentials or fragment.',
);
}
return url.toString();
}

function readSecret(value: string | undefined, name: string, required: boolean): Buffer {
if (!value) {
if (required) {
return missingSecret(name);
}
return randomBytes(32);
}
return decodeSecret(value, name);
}

function decodeSecret(value: string, name: string): Buffer {
if (!/^[A-Za-z0-9_-]{43,}$/.test(value)) {
throw new Error(`${name} must be an unpadded base64url value containing at least 32 bytes.`);
}
const decoded = Buffer.from(value, 'base64url');
if (decoded.length < 32) {
throw new Error(`${name} must contain at least 32 bytes.`);
}
return decoded;
}

function missingSecret(name: string): never {
throw new Error(`${name} is required in production.`);
}

function readInteger(
value: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
if (!value) {
return fallback;
}
if (!/^\d+$/.test(value)) {
throw new Error(`Authentication TTL must be an integer between ${minimum} and ${maximum}.`);
}
const parsed = Number.parseInt(value, 10);
if (parsed < minimum || parsed > maximum) {
throw new Error(`Authentication TTL must be an integer between ${minimum} and ${maximum}.`);
}
return parsed;
}

function assertHttpsEndpoint(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} must be an absolute HTTPS URL.`);
}
if (
url.protocol !== 'https:' ||
url.username !== '' ||
url.password !== '' ||
url.search !== '' ||
url.hash !== ''
) {
throw new Error(`${name} must be HTTPS without credentials, query, or fragment.`);
}
}
Loading