FastAPI and TypeScript improved
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef } from './types'
|
||||
import { ReactContext, type ClientOptions, type RegistryEntry, type ParamDef, type AuthRequirement } from './types'
|
||||
import { register } from './registry'
|
||||
|
||||
function resolveContext(ctx: ReactContext | string | undefined): string | undefined {
|
||||
@@ -21,6 +21,19 @@ function resolveContext(ctx: ReactContext | string | undefined): string | undefi
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the public auth option into the stored requirement.
|
||||
* Mirrors Python: undefined→undefined, true→'required', callable→callable,
|
||||
* 'staff'/'superuser' pass through, anything else throws at decoration time.
|
||||
*/
|
||||
function normalizeAuth(auth: ClientOptions['auth']): AuthRequirement | undefined {
|
||||
if (auth === undefined) return undefined
|
||||
if (auth === true) return 'required'
|
||||
if (typeof auth === 'function') return auth
|
||||
if (auth === 'staff' || auth === 'superuser') return auth
|
||||
throw new Error(`Invalid auth value ${JSON.stringify(auth)}`)
|
||||
}
|
||||
|
||||
function normalizeAffects(
|
||||
affects: ClientOptions['affects'],
|
||||
): RegistryEntry['affects'] | undefined {
|
||||
@@ -97,7 +110,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
viewPath: isView,
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
auth: normalizeAuth(options.auth),
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
@@ -129,7 +142,7 @@ export function client(optionsOrFn: ClientOptions | ClientOptions, fn?: Function
|
||||
viewPath: false,
|
||||
route: options.route,
|
||||
methods: options.methods,
|
||||
auth: options.auth,
|
||||
auth: normalizeAuth(options.auth),
|
||||
rev: options.rev,
|
||||
cache: options.cache,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { getFunction, getContextGroups } from './registry'
|
||||
import { resolveInvalidation, formatInvalidateHeader } from './invalidation'
|
||||
import { getCache, cacheGet, cachePut, cachePurge } from './cache'
|
||||
import { ANONYMOUS, type Identity } from './identity'
|
||||
import type { AuthRequirement } from './types'
|
||||
|
||||
let _cacheSecret: string | null = null
|
||||
|
||||
@@ -22,6 +24,54 @@ export interface MizanResponse {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
interface AuthDenial {
|
||||
status: 401 | 403
|
||||
code: 'UNAUTHORIZED' | 'FORBIDDEN'
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether `identity` satisfies the stored `auth` requirement.
|
||||
* Ports Django's _check_auth_requirement exactly. Returns an AuthDenial
|
||||
* on failure, or null when access is allowed.
|
||||
*/
|
||||
function checkAuth(auth: AuthRequirement | undefined, identity: Identity): AuthDenial | null {
|
||||
if (auth === undefined) return null
|
||||
|
||||
// Callable runs first — before the authentication gate.
|
||||
if (typeof auth === 'function') {
|
||||
try {
|
||||
return auth(identity)
|
||||
? null
|
||||
: { status: 403, code: 'FORBIDDEN', message: 'Access denied' }
|
||||
} catch (e: any) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: e?.message || 'Access denied' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!identity.isAuthenticated) {
|
||||
return { status: 401, code: 'UNAUTHORIZED', message: 'Authentication required' }
|
||||
}
|
||||
|
||||
if (auth === 'staff' && !identity.isStaff) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: 'Staff access required' }
|
||||
}
|
||||
|
||||
if (auth === 'superuser' && !identity.isSuperuser) {
|
||||
return { status: 403, code: 'FORBIDDEN', message: 'Superuser access required' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function authDenialResponse(denial: AuthDenial): MizanResponse {
|
||||
return {
|
||||
status: denial.status,
|
||||
body: { error: true, code: denial.code, message: denial.message },
|
||||
headers: { 'Cache-Control': 'no-store', 'Content-Type': 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /api/mizan/ctx/:contextName/
|
||||
*
|
||||
@@ -30,6 +80,7 @@ export interface MizanResponse {
|
||||
export async function handleContextFetch(
|
||||
contextName: string,
|
||||
params: Record<string, string>,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanResponse> {
|
||||
const groups = getContextGroups()
|
||||
const fnNames = groups[contextName]
|
||||
@@ -42,6 +93,15 @@ export async function handleContextFetch(
|
||||
}
|
||||
}
|
||||
|
||||
// Auth pre-pass — run BEFORE the cache lookup so a cache HIT can never
|
||||
// leak to an unauthorized caller. Any denial short-circuits, uncached.
|
||||
for (const fnName of fnNames) {
|
||||
const entry = getFunction(fnName)
|
||||
if (!entry) continue
|
||||
const denial = checkAuth(entry.auth, identity)
|
||||
if (denial) return authDenialResponse(denial)
|
||||
}
|
||||
|
||||
// Resolve effective rev (max across functions) and cache policy (min TTL)
|
||||
let effectiveRev = 0
|
||||
for (const fnName of fnNames) {
|
||||
@@ -133,6 +193,7 @@ export async function handleContextFetch(
|
||||
export async function handleMutationCall(
|
||||
fnName: string,
|
||||
args: Record<string, any>,
|
||||
identity: Identity = ANONYMOUS,
|
||||
): Promise<MizanResponse> {
|
||||
const entry = getFunction(fnName)
|
||||
|
||||
@@ -153,6 +214,10 @@ export async function handleMutationCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Auth enforcement — after private rejection, before execution.
|
||||
const denial = checkAuth(entry.auth, identity)
|
||||
if (denial) return authDenialResponse(denial)
|
||||
|
||||
try {
|
||||
const argValues = entry.params.map(p => args[p.name])
|
||||
const result = await entry.fn(...argValues)
|
||||
|
||||
22
backends/mizan-ts/src/identity.ts
Normal file
22
backends/mizan-ts/src/identity.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Identity abstraction — the request-bound caller identity.
|
||||
*
|
||||
* Framework-agnostic. Adapters construct an Identity (from MWT, JWT,
|
||||
* session, etc.) and pass it into dispatch. ANONYMOUS is the default.
|
||||
*/
|
||||
|
||||
export interface Identity {
|
||||
isAuthenticated: boolean
|
||||
isStaff: boolean
|
||||
isSuperuser: boolean
|
||||
id: number | string | null
|
||||
}
|
||||
|
||||
export const ANONYMOUS: Identity = {
|
||||
isAuthenticated: false,
|
||||
isStaff: false,
|
||||
isSuperuser: false,
|
||||
id: null,
|
||||
}
|
||||
|
||||
export type AuthPredicate = (identity: Identity) => boolean
|
||||
@@ -1,5 +1,11 @@
|
||||
export { ReactContext } from './types'
|
||||
export type { ClientOptions, EdgeManifest, RegistryEntry } from './types'
|
||||
export type { ClientOptions, EdgeManifest, RegistryEntry, AuthOption, AuthRequirement } from './types'
|
||||
|
||||
export { ANONYMOUS } from './identity'
|
||||
export type { Identity, AuthPredicate } from './identity'
|
||||
|
||||
export { decodeMwt, decodeJwtBearer, identityFromMwt } from './token'
|
||||
export type { MwtPayload } from './token'
|
||||
|
||||
export { client } from './decorator'
|
||||
|
||||
|
||||
110
backends/mizan-ts/src/token.ts
Normal file
110
backends/mizan-ts/src/token.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* MWT / JWT decode — HS256 verification, cross-language parity with
|
||||
* cores/mizan-python/src/mizan_core/mwt.py.
|
||||
*
|
||||
* Returns null on ANY failure (bad signature, expired, future nbf, wrong
|
||||
* aud, malformed). Never throws.
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import type { Identity } from './identity'
|
||||
|
||||
export interface MwtPayload {
|
||||
sub: string
|
||||
staff: boolean
|
||||
super: boolean
|
||||
pkey: string
|
||||
kid: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64urlDecode(input: string): Buffer | null {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(input)) return null
|
||||
return Buffer.from(input, 'base64url')
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode and validate an MWT (HS256 JWT with Mizan claims).
|
||||
* Returns MwtPayload on success, null on any failure.
|
||||
*/
|
||||
export function decodeMwt(
|
||||
token: string,
|
||||
secret: string,
|
||||
audience: string = 'mizan',
|
||||
): MwtPayload | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
const [headerB64, payloadB64, signatureB64] = parts
|
||||
|
||||
const headerBytes = base64urlDecode(headerB64)
|
||||
const payloadBytes = base64urlDecode(payloadB64)
|
||||
const signatureBytes = base64urlDecode(signatureB64)
|
||||
if (!headerBytes || !payloadBytes || !signatureBytes) return null
|
||||
|
||||
const header = JSON.parse(headerBytes.toString('utf-8'))
|
||||
if (header.alg !== 'HS256') return null
|
||||
|
||||
// Recompute HMAC over `${headerB64}.${payloadB64}`
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(`${headerB64}.${payloadB64}`)
|
||||
.digest()
|
||||
if (!constantTimeEqual(expected, signatureBytes)) return null
|
||||
|
||||
const data = JSON.parse(payloadBytes.toString('utf-8'))
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (typeof data.exp !== 'number' || data.exp <= now) return null
|
||||
if (data.nbf !== undefined && typeof data.nbf === 'number' && data.nbf > now) return null
|
||||
if (data.aud !== audience) return null
|
||||
|
||||
const kid = typeof header.kid === 'string' ? header.kid : 'v1'
|
||||
|
||||
return {
|
||||
sub: String(data.sub),
|
||||
staff: Boolean(data.staff),
|
||||
super: Boolean(data.super),
|
||||
pkey: typeof data.pkey === 'string' ? data.pkey : '',
|
||||
kid,
|
||||
aud: audience,
|
||||
iat: data.iat,
|
||||
exp: data.exp,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Bearer JWT from an Authorization header value.
|
||||
* Strips the "Bearer " prefix, then validates as an MWT.
|
||||
*/
|
||||
export function decodeJwtBearer(
|
||||
authHeader: string,
|
||||
secret: string,
|
||||
audience: string = 'mizan',
|
||||
): MwtPayload | null {
|
||||
if (!authHeader) return null
|
||||
const prefix = 'Bearer '
|
||||
const token = authHeader.startsWith(prefix)
|
||||
? authHeader.slice(prefix.length)
|
||||
: authHeader
|
||||
return decodeMwt(token, secret, audience)
|
||||
}
|
||||
|
||||
/** Build an Identity from a decoded MWT payload. */
|
||||
export function identityFromMwt(payload: MwtPayload): Identity {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
isStaff: payload.staff,
|
||||
isSuperuser: payload.super,
|
||||
id: Number(payload.sub),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* Mizan TypeScript Adapter — Shared Types
|
||||
*/
|
||||
|
||||
import type { AuthPredicate } from './identity'
|
||||
|
||||
export class ReactContext {
|
||||
constructor(public readonly name: string) {
|
||||
if (!name) throw new Error('ReactContext name must be non-empty')
|
||||
@@ -10,13 +12,19 @@ export class ReactContext {
|
||||
|
||||
export type AffectsTarget = ReactContext | string
|
||||
|
||||
/** Public auth option on the decorator. `true` normalizes to `'required'` when stored. */
|
||||
export type AuthOption = true | 'staff' | 'superuser' | AuthPredicate
|
||||
|
||||
/** Normalized auth requirement as stored on the registry entry. */
|
||||
export type AuthRequirement = 'required' | 'staff' | 'superuser' | AuthPredicate
|
||||
|
||||
export interface ClientOptions {
|
||||
context?: ReactContext | string
|
||||
affects?: AffectsTarget | AffectsTarget[]
|
||||
private?: boolean
|
||||
route?: string
|
||||
methods?: string[]
|
||||
auth?: boolean
|
||||
auth?: AuthOption
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
@@ -37,7 +45,7 @@ export interface RegistryEntry {
|
||||
viewPath: boolean
|
||||
route?: string
|
||||
methods?: string[]
|
||||
auth?: boolean
|
||||
auth?: AuthRequirement
|
||||
rev?: number
|
||||
cache?: number | false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user