Skip to content

feat: generate JWT and API key auth for TypeScript, Go and Python - #165

Merged
cultron merged 2 commits into
mainfrom
feat/jwt-auth
Oct 29, 2026
Merged

cultron merged 2 commits into
mainfrom
feat/jwt-auth

Conversation

@cultron

@cultron cultron commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Why

auth0, clerk, cognito and api-key were accepted in .apsorc but generated no token check. TypeScript returned no guard for them, Go wrote an unwired placeholder that accepted any bearer token, and Python wrote nothing. The docs presented all of them as working.

While testing end to end, these turned up too:

  • TypeScript: autogen/index.ts never listed GuardsModule, so no generated guard ran in a template project. That includes better-auth and every scopeBy filter.
  • TypeScript: the scope guard's list filter used a query syntax @apso/crud ignores, and it compared numeric ids to string claims.
  • Go: enum fields referenced a type name the enums package never declares, so they didn't compile.
  • Python: enum types weren't created under DATABASE_SYNC, and creating a row with a relationship returned 500.
  • Python template (fixed in fix: serve generated routes; install greenlet for async SQLAlchemy service-template-python#7): the generated router was never mounted, and greenlet was missing.

What

  • A new generic jwt provider for any issuer that publishes a JWKS. Auth0, Clerk and Cognito get claim defaults, and Cognito checks client_id on access tokens. Only asymmetric algorithms are accepted.
  • AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc values at runtime.
  • API keys: the key's SHA-256 is matched against ApiKey.keyHash, and status and expiresAt are enforced when present.
  • TypeScript:
    • a jose JWT guard and an API key guard;
    • GuardsModule wired in;
    • @CrudAuth on scoped controllers, so reads and writes stay in the caller's tenant;
    • the scope guard fails closed when the caller has no tenant;
    • GET /health stays public.
  • Go: middleware on the /api group.
  • Python: a require_auth dependency on the generated router.
  • Not supported yet: Go and Python refuse better-auth and custom-db-session with a hint to use jwt via Better Auth's JWT plugin. scopeBy is enforced in TypeScript only, and Go and Python print a warning.

Tests

  • test/lib/guards/auth-providers.test.ts: 37 unit tests. The full suite passes (514).
  • scripts/auth-smoke.mjs, plus the new auth-smoke workflow (PRs touching generators, weekly). For each language and each provider (auth0, clerk, cognito, jwt, api-key) it generates the service, starts it on Postgres, and checks:
    • 401 for missing, garbage, expired, wrong-issuer, wrong-audience, wrong-key, alg-none, HS256-with-public-key and no-subject tokens;
    • 401 for unknown, revoked and expired API keys;
    • CRUD succeeds with valid credentials, and /health is public;
    • in TypeScript, cross-tenant reads get 403, lists exclude other tenants' rows, a body can't pick another tenant, and a caller with no tenant gets 403.

Locally (Postgres 16, Node 24, Go 1.25, Python 3.12), every check passes in all three languages.

auth0, clerk, cognito and api-key were accepted in .apsorc but generated no
check: TypeScript returned no guard for them, Go wrote an unwired placeholder
that accepted any bearer token, and Python wrote nothing.

- New generic "jwt" provider for any issuer with a JWKS (Supabase Auth,
  Firebase, WorkOS, Okta, Better Auth's JWT plugin). audience is optional;
  Cognito checks client_id on access tokens. Only asymmetric algorithms.
- TypeScript: jose-based JWT guard and a hashed API key guard. GuardsModule is
  now listed in autogen/index.ts; before this no generated guard (including
  better-auth and scopeBy) ever ran in a template project. GET /health stays
  public.
- scopeBy: scoped controllers get @CrudAuth when auth is configured, so every
  query and write is limited to the caller's tenant; the scope guard fails
  closed when the caller has no tenant and compares ids as strings.
- Go: golang-jwt + keyfunc middleware, or a hashed API key lookup, registered
  on the /api group. Enum fields now use the declared type name (they did not
  compile).
- Python: a require_auth dependency on the generated router (PyJWT, or a
  hashed API key lookup). Enum types are created on DATABASE_SYNC, and related
  rows serialize as their columns instead of failing with a 500.
- Go and Python refuse better-auth/custom-db-session instead of generating an
  open API.
- generate adds jose / PyJWT[crypto] to the project.
- scripts/auth-smoke.mjs + auth-smoke workflow: per language and provider,
  start the service on Postgres and check 401s for bad credentials, CRUD with
  good ones, and tenant isolation.
Better Auth's JWT plugin signs with EdDSA by default, and Clerk recommends
rejecting tokens whose azp is not one of your origins. Both are covered in
the auth smoke for all three languages.
@cultron
cultron merged commit f679de9 into main Oct 29, 2026
7 checks passed
@cultron
cultron deleted the feat/jwt-auth branch October 29, 2026 03:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant