Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
isInstallTokenMessage,
mintInstallToken,
verifyInstallToken,
} from '../install-token';

const ORIGINAL_KEY = process.env.ENCRYPTION_KEY;

describe('install-token', () => {
beforeAll(() => {
process.env.ENCRYPTION_KEY =
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
});
afterAll(() => {
if (ORIGINAL_KEY === undefined) delete process.env.ENCRYPTION_KEY;
else process.env.ENCRYPTION_KEY = ORIGINAL_KEY;
});

describe('mint + verify round-trip', () => {
it('verifies a freshly minted token', () => {
const token = mintInstallToken({
userId: 'user_abc',
templateAgentId: 'agent_xyz',
});
expect(token.startsWith('install:')).toBe(true);

const result = verifyInstallToken(token);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.userId).toBe('user_abc');
expect(result.templateAgentId).toBe('agent_xyz');
expect(result.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000));
}
});

it('detects tampered payload via HMAC mismatch', () => {
const token = mintInstallToken({
userId: 'user_abc',
templateAgentId: 'agent_xyz',
});
const [head, sig] = token.slice('install:'.length).split('.');
const tamperedPayload = Buffer.from(
JSON.stringify({ u: 'attacker', t: 'agent_xyz', e: Math.floor(Date.now() / 1000) + 600 })
).toString('base64url');
const tampered = `install:${tamperedPayload}.${sig}`;
const result = verifyInstallToken(tampered);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('bad_signature');
expect(head).toBeTruthy();
});

it('rejects expired tokens', () => {
const token = mintInstallToken({
userId: 'user_abc',
templateAgentId: 'agent_xyz',
ttlSeconds: -1,
});
const result = verifyInstallToken(token);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('expired');
});

it('rejects malformed tokens', () => {
expect(verifyInstallToken('not a token').ok).toBe(false);
expect(verifyInstallToken('install:').ok).toBe(false);
expect(verifyInstallToken('install:abc').ok).toBe(false);
expect(verifyInstallToken('install:abc.def').ok).toBe(false);
});

it('rejects tokens minted with a different secret', () => {
const token = mintInstallToken({
userId: 'user_abc',
templateAgentId: 'agent_xyz',
});
const oldKey = process.env.ENCRYPTION_KEY;
process.env.ENCRYPTION_KEY =
'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210';
const result = verifyInstallToken(token);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('bad_signature');
process.env.ENCRYPTION_KEY = oldKey;
});
});

describe('isInstallTokenMessage', () => {
it('accepts the canonical prefix', () => {
expect(isInstallTokenMessage('install:abc.def')).toBe(true);
expect(isInstallTokenMessage(' install:abc.def')).toBe(true);
});

it('rejects normal chat messages', () => {
expect(isInstallTokenMessage('hello')).toBe(false);
expect(isInstallTokenMessage('install me please')).toBe(false);
expect(isInstallTokenMessage('install: foo')).toBe(true); // a malformed token still claims to be one
});
});
});
54 changes: 54 additions & 0 deletions packages/owletto-backend/src/agents/install-token-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* POST /api/install/token
* Mints a short-lived install token for a signed-in user. The landing page
* embeds it in a `https://wa.me/<bot-phone>?text=install:<token>` link so the
* user can claim the install via WhatsApp without re-typing their phone
* number.
*/

import { type Context, Hono } from 'hono';
import { requireAuth } from '../auth/middleware';
import type { Env } from '../index';
import { errorMessage } from '../utils/errors';
import { mintInstallToken } from './install-token';

const installTokenRoutes = new Hono<{ Bindings: Env }>();

function getAuthenticatedUser(c: Context<{ Bindings: Env }>) {
const user = c.get('user');
if (!user) throw new Error('Authenticated user missing from context');
return user;
}

installTokenRoutes.post('/install/token', requireAuth, async (c) => {
const user = getAuthenticatedUser(c);

let body: { templateAgentId?: string };
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}

if (!body.templateAgentId || typeof body.templateAgentId !== 'string') {
return c.json({ error: 'templateAgentId is required' }, 400);
}

try {
const token = mintInstallToken({
userId: user.id,
templateAgentId: body.templateAgentId,
});
return c.json({
token,
expiresInSeconds: 15 * 60,
// wa.me link is constructed by the caller — they know the bot's phone
// number from the connection config (or hard-coded in the landing
// page). We don't ship phone numbers from this endpoint.
});
} catch (error) {
return c.json({ error: errorMessage(error) }, 500);
}
});

export { installTokenRoutes };
115 changes: 115 additions & 0 deletions packages/owletto-backend/src/agents/install-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Install tokens — short-lived HMAC-signed claims that authorize installing
* a specific template agent into a specific user's personal org via a
* non-web channel (e.g. inbound WhatsApp message containing the token).
*
* Format:
* install:<base64url(payload)>.<base64url(hmac-sha256)>
* Payload (JSON): { u: userId, t: templateAgentId, e: expiryEpochSeconds }
*
* Stateless — no DB row. Re-using a token within the expiry window is fine
* (idempotent install). Once the token expires the user gets a fresh one
* from the landing page.
*/

import { createHmac, timingSafeEqual } from 'node:crypto';

const TOKEN_PREFIX = 'install:';
const TOKEN_TTL_SECONDS = 15 * 60;

interface TokenPayload {
u: string; // userId
t: string; // templateAgentId
e: number; // expiry, epoch seconds
}

function getSecret(): Buffer {
const raw = process.env.ENCRYPTION_KEY;
if (!raw) {
throw new Error('ENCRYPTION_KEY is required to mint install tokens');
}
// Domain-separate from the encryption key by hashing once with a label.
return createHmac('sha256', raw).update('install-token:v1').digest();
}

function base64UrlEncode(buf: Buffer | string): string {
const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf;
return b.toString('base64url');
}

function base64UrlDecode(s: string): Buffer {
return Buffer.from(s, 'base64url');
}

export function mintInstallToken(params: {
userId: string;
templateAgentId: string;
ttlSeconds?: number;
}): string {
const ttl = params.ttlSeconds ?? TOKEN_TTL_SECONDS;
const payload: TokenPayload = {
u: params.userId,
t: params.templateAgentId,
e: Math.floor(Date.now() / 1000) + ttl,
};
const payloadEncoded = base64UrlEncode(JSON.stringify(payload));
const sig = createHmac('sha256', getSecret()).update(payloadEncoded).digest();
return `${TOKEN_PREFIX}${payloadEncoded}.${base64UrlEncode(sig)}`;
}

interface VerifyOk {
ok: true;
userId: string;
templateAgentId: string;
expiresAt: number;
}
interface VerifyErr {
ok: false;
error: 'malformed' | 'bad_signature' | 'expired';
}

export function verifyInstallToken(input: string): VerifyOk | VerifyErr {
if (!input.startsWith(TOKEN_PREFIX)) return { ok: false, error: 'malformed' };
const body = input.slice(TOKEN_PREFIX.length).trim();
const [payloadEncoded, sigEncoded] = body.split('.');
if (!payloadEncoded || !sigEncoded) return { ok: false, error: 'malformed' };

const expected = createHmac('sha256', getSecret()).update(payloadEncoded).digest();
let provided: Buffer;
try {
provided = base64UrlDecode(sigEncoded);
} catch {
return { ok: false, error: 'malformed' };
}
if (provided.length !== expected.length) return { ok: false, error: 'bad_signature' };
if (!timingSafeEqual(provided, expected)) return { ok: false, error: 'bad_signature' };

let payload: TokenPayload;
try {
payload = JSON.parse(base64UrlDecode(payloadEncoded).toString('utf8')) as TokenPayload;
} catch {
return { ok: false, error: 'malformed' };
}
if (
typeof payload.u !== 'string' ||
typeof payload.t !== 'string' ||
typeof payload.e !== 'number'
) {
return { ok: false, error: 'malformed' };
}

if (Math.floor(Date.now() / 1000) >= payload.e) {
return { ok: false, error: 'expired' };
}
return {
ok: true,
userId: payload.u,
templateAgentId: payload.t,
expiresAt: payload.e,
};
}

/** True when an inbound chat message looks like an install token claim. */
export function isInstallTokenMessage(text: string): boolean {
return text.trim().startsWith(TOKEN_PREFIX);
}
Loading
Loading