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
172 changes: 172 additions & 0 deletions apps/api/src/auth/authenticate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { describe, it } from 'node:test';
import type { HttpRequest } from '@azure/functions';
import { DEFAULT_POC_SCOPES, PostKitErrorCode, type Principal } from '@singleton-sd/post-kit-types';
import {
ApiKeyAuthenticator,
AuthError,
extractBearerToken,
principalFromTenantKeyMap,
principalIdFromApiKey,
requireScope,
tenantContextFromPrincipal,
} from './authenticate';
import { MCP_TOOL_SCOPES, scopeForMcpTool } from './mcp-scopes';

const KEY_MAP = {
tk_live_abc123: { tenantId: 'inkads', environment: 'production' as const },
tk_dev_xyz789: { tenantId: 'inkads', environment: 'development' as const },
};

function makeRequest(headers: Record<string, string | undefined>): HttpRequest {
const map = new Map<string, string>();
for (const [k, v] of Object.entries(headers)) {
if (v !== undefined) map.set(k.toLowerCase(), v);
}
return {
headers: {
get: (name: string) => map.get(name.toLowerCase()) ?? null,
},
} as unknown as HttpRequest;
}

describe('principalIdFromApiKey', () => {
it('returns a truncated sha256 prefix and never equals the raw token', () => {
const token = 'tk_live_abc123';
const id = principalIdFromApiKey(token);
const expected = `ak_${createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16)}`;
assert.equal(id, expected);
assert.notEqual(id, token);
assert.ok(!id.includes(token));
});
});

describe('extractBearerToken', () => {
it('throws UNAUTHENTICATED when Authorization is missing', () => {
assert.throws(
() => extractBearerToken(null),
(err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED,
);
});

it('throws UNAUTHENTICATED for non-Bearer scheme', () => {
assert.throws(
() => extractBearerToken('Basic abc'),
(err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED,
);
});

it('accepts case-insensitive Bearer with extra spaces', () => {
assert.equal(extractBearerToken('bearer tk_live_abc123'), 'tk_live_abc123');
});
});

describe('principalFromTenantKeyMap', () => {
it('builds a Principal with default PoC scopes for legacy map entries', () => {
const principal = principalFromTenantKeyMap('tk_live_abc123', KEY_MAP);
assert.equal(principal.tenantId, 'inkads');
assert.equal(principal.environment, 'production');
assert.equal(principal.authType, 'api-key');
assert.deepEqual([...principal.scopes], [...DEFAULT_POC_SCOPES]);
assert.equal(principal.id, principalIdFromApiKey('tk_live_abc123'));
});

it('throws UNAUTHORIZED for unknown tokens without echoing the token', () => {
try {
principalFromTenantKeyMap('tk_unknown', KEY_MAP);
assert.fail('expected throw');
} catch (err) {
assert.ok(err instanceof AuthError);
assert.equal(err.code, PostKitErrorCode.UNAUTHORIZED);
assert.ok(!err.message.includes('tk_unknown'));
}
});

it('rejects prototype-chain names as tokens', () => {
assert.throws(
() => principalFromTenantKeyMap('toString', KEY_MAP),
(err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHORIZED,
);
});
});

describe('ApiKeyAuthenticator', () => {
const auth = new ApiKeyAuthenticator(KEY_MAP);

it('authenticates a valid Bearer credential to a Principal', async () => {
const principal = await auth.authenticate(
makeRequest({ authorization: 'Bearer tk_dev_xyz789' }),
);
assert.equal(principal.tenantId, 'inkads');
assert.equal(principal.environment, 'development');
assert.deepEqual([...principal.scopes], [...DEFAULT_POC_SCOPES]);
});

it('rejects missing credentials', async () => {
await assert.rejects(
() => auth.authenticate(makeRequest({})),
(err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED,
);
});

it('rejects unknown credentials', async () => {
await assert.rejects(
() => auth.authenticate(makeRequest({ authorization: 'Bearer wrong' })),
(err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHORIZED,
);
});
});

describe('requireScope', () => {
const base: Principal = {
id: 'ak_test',
tenantId: 'acme',
environment: 'development',
authType: 'api-key',
scopes: ['templates:read'],
};

it('allows when the principal holds the scope', () => {
assert.doesNotThrow(() => requireScope(base, 'templates:read'));
});

it('throws UNAUTHORIZED when the scope is missing (non-sensitive message)', () => {
try {
requireScope(base, 'email:send');
assert.fail('expected throw');
} catch (err) {
assert.ok(err instanceof AuthError);
assert.equal(err.code, PostKitErrorCode.UNAUTHORIZED);
assert.equal(err.message, 'The credential does not have the required permission.');
assert.ok(!err.message.includes('ak_test'));
}
});
});

describe('tenantContextFromPrincipal', () => {
it('exposes tenantId and environment from the principal', () => {
const principal: Principal = {
id: 'ak_x',
tenantId: 'acme',
environment: 'staging',
authType: 'api-key',
scopes: DEFAULT_POC_SCOPES,
};
assert.deepEqual(tenantContextFromPrincipal(principal), {
tenantId: 'acme',
environment: 'staging',
});
});
});

describe('MCP_TOOL_SCOPES', () => {
it('maps every MCP tool to a semantic scope', () => {
assert.equal(scopeForMcpTool('postkit.list_templates'), 'templates:read');
assert.equal(scopeForMcpTool('postkit.get_template'), 'templates:read');
assert.equal(scopeForMcpTool('postkit.get_template_schema'), 'templates:read');
assert.equal(scopeForMcpTool('postkit.validate_template'), 'templates:validate');
assert.equal(scopeForMcpTool('postkit.preview_template'), 'templates:preview');
assert.equal(Object.keys(MCP_TOOL_SCOPES).length, 5);
});
});
135 changes: 135 additions & 0 deletions apps/api/src/auth/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { HttpRequest } from '@azure/functions';
import {
DEFAULT_POC_SCOPES,
PostKitErrorCode,
type Principal,
type PostKitScope,
} from '@singleton-sd/post-kit-types';
import { createHash } from 'node:crypto';
import type { TenantKeyMap } from '../tenant';

/**
* Error thrown by authentication / authorization helpers.
* Carries a stable PostKitErrorCode; never includes raw credentials.
*/
export class AuthError extends Error {
readonly code: PostKitErrorCode;

constructor(message: string, code: PostKitErrorCode) {
super(message);
this.name = 'AuthError';
this.code = code;
}
}

/**
* Resolves a Principal from an incoming HTTP request.
* Implementations must never accept tenant identity or scopes from the body.
*/
export interface Authenticator {
authenticate(request: HttpRequest): Promise<Principal>;
}

/**
* Opaque principal id derived from the credential without retaining plaintext.
* Truncated SHA-256 — never log the raw token.
*/
export function principalIdFromApiKey(token: string): string {
const digest = createHash('sha256').update(token, 'utf8').digest('hex');
return `ak_${digest.slice(0, 16)}`;
}

/**
* Extract the Bearer token from an Authorization header.
* Never returns or logs the token in error messages.
*/
export function extractBearerToken(authHeader: string | null): string {
if (!authHeader) {
throw new AuthError('Authorization header is missing.', PostKitErrorCode.UNAUTHENTICATED);
}

// RFC 7235: auth-scheme is case-insensitive; accept one or more spaces.
const bearerPrefixMatch = /^bearer +/i.exec(authHeader);
if (!bearerPrefixMatch) {
throw new AuthError(
'Authorization header must use the Bearer scheme.',
PostKitErrorCode.UNAUTHENTICATED,
);
}

const token = authHeader.slice(bearerPrefixMatch[0].length);
if (!token) {
throw new AuthError('Bearer token is empty.', PostKitErrorCode.UNAUTHENTICATED);
}

return token;
}

/**
* Look up a plaintext TENANT_KEY_MAP entry and build a Principal.
* Legacy map entries receive {@link DEFAULT_POC_SCOPES} so send + MCP keep working.
*/
export function principalFromTenantKeyMap(
token: string,
keyMap: TenantKeyMap,
scopes: readonly PostKitScope[] = DEFAULT_POC_SCOPES,
): Principal {
const entry = Object.prototype.hasOwnProperty.call(keyMap, token) ? keyMap[token] : undefined;

if (!entry) {
throw new AuthError(
'The provided credential does not map to a known tenant.',
PostKitErrorCode.UNAUTHORIZED,
);
}

return {
id: principalIdFromApiKey(token),
tenantId: entry.tenantId,
environment: entry.environment,
authType: 'api-key',
scopes: [...scopes],
};
}

/**
* Authenticate an HTTP request via Bearer token + plaintext TENANT_KEY_MAP (PoC).
* Dual-read plaintext only — hashed store lands in Slice B.
*/
export class ApiKeyAuthenticator implements Authenticator {
private readonly keyMap: TenantKeyMap;
private readonly scopes: readonly PostKitScope[];

constructor(keyMap: TenantKeyMap, scopes: readonly PostKitScope[] = DEFAULT_POC_SCOPES) {
this.keyMap = keyMap;
this.scopes = scopes;
}

async authenticate(request: HttpRequest): Promise<Principal> {
const token = extractBearerToken(request.headers.get('authorization'));
return principalFromTenantKeyMap(token, this.keyMap, this.scopes);
}
}

/**
* Require that the principal holds the given scope.
* Throws AuthError with UNAUTHORIZED — message is non-sensitive (no raw keys).
*/
export function requireScope(principal: Principal, scope: PostKitScope): void {
if (!principal.scopes.includes(scope)) {
throw new AuthError(
'The credential does not have the required permission.',
PostKitErrorCode.UNAUTHORIZED,
);
}
}

/**
* Tenant identity derived from the authenticated principal (never from tool/body input).
*/
export function tenantContextFromPrincipal(principal: Principal): {
tenantId: string;
environment: Principal['environment'];
} {
return { tenantId: principal.tenantId, environment: principal.environment };
}
11 changes: 11 additions & 0 deletions apps/api/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export {
ApiKeyAuthenticator,
AuthError,
extractBearerToken,
principalFromTenantKeyMap,
principalIdFromApiKey,
requireScope,
tenantContextFromPrincipal,
type Authenticator,
} from './authenticate';
export { MCP_TOOL_SCOPES, scopeForMcpTool, type McpScopedToolName } from './mcp-scopes';
19 changes: 19 additions & 0 deletions apps/api/src/auth/mcp-scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { PostKitScope } from '@singleton-sd/post-kit-types';

/**
* Central MCP tool → scope map.
* Enforced in create-server runTool — tools must not re-implement ad-hoc checks.
*/
export const MCP_TOOL_SCOPES = {
'postkit.list_templates': 'templates:read',
'postkit.get_template': 'templates:read',
'postkit.get_template_schema': 'templates:read',
'postkit.validate_template': 'templates:validate',
'postkit.preview_template': 'templates:preview',
} as const satisfies Record<string, PostKitScope>;

export type McpScopedToolName = keyof typeof MCP_TOOL_SCOPES;

export function scopeForMcpTool(tool: McpScopedToolName): PostKitScope {
return MCP_TOOL_SCOPES[tool];
}
2 changes: 1 addition & 1 deletion apps/api/src/functions/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createProductionMcpHandler } from '../mcp/handler';

/**
* Stateless MCP Streamable HTTP endpoint on the existing Function App.
* Auth: Bearer API key via TENANT_KEY_MAP (same as REST send). PoC only — see #83.
* Auth: Bearer API key → Principal + scopes (TENANT_KEY_MAP PoC; see #83).
*/
app.http('mcp', {
methods: ['POST', 'GET', 'DELETE', 'OPTIONS'],
Expand Down
Loading
Loading