@apso/cli 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/generate.js +21 -1
- package/dist/commands/mcp/serve.js +30 -9
- package/dist/lib/generators/base.d.ts +1 -0
- package/dist/lib/generators/go.d.ts +2 -1
- package/dist/lib/generators/go.js +34 -4
- package/dist/lib/generators/python.d.ts +4 -2
- package/dist/lib/generators/python.js +28 -6
- package/dist/lib/generators/typescript.d.ts +2 -1
- package/dist/lib/generators/typescript.js +43 -6
- package/dist/lib/guards.d.ts +11 -1
- package/dist/lib/guards.js +20 -1
- package/dist/lib/templates/go/index-module.eta +6 -0
- package/dist/lib/templates/go/middleware/auth.eta +299 -59
- package/dist/lib/templates/guards/api-key-auth.guard.eta +146 -0
- package/dist/lib/templates/guards/auth.guard.eta +6 -0
- package/dist/lib/templates/guards/guards.module.eta +11 -19
- package/dist/lib/templates/guards/index.eta +2 -2
- package/dist/lib/templates/guards/jwt-auth.guard.eta +172 -0
- package/dist/lib/templates/guards/scope.guard.eta +20 -2
- package/dist/lib/templates/python/auth.eta +217 -0
- package/dist/lib/templates/python/index-module.eta +11 -0
- package/dist/lib/templates/python/models/model-col-enum.eta +1 -1
- package/dist/lib/templates/python/schemas/schema.eta +22 -1
- package/dist/lib/templates/rest/controller-rest.eta +16 -1
- package/dist/lib/templates/rest/index-module-rest.eta +6 -0
- package/dist/lib/types/auth.d.ts +41 -7
- package/dist/lib/types/auth.js +61 -2
- package/dist/lib/types/generator.d.ts +6 -0
- package/dist/lib/types/index.d.ts +1 -1
- package/dist/lib/types/index.js +3 -1
- package/dist/lib/utils/crud-deps.d.ts +13 -0
- package/dist/lib/utils/crud-deps.js +37 -1
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthGuard - verifies <%= it.jwt.provider %> JWTs against the issuer's JWKS
|
|
3
|
+
*
|
|
4
|
+
* Auto-generated by Apso CLI from .apsorc auth configuration
|
|
5
|
+
* Generated at: <%= it.generatedAt %>
|
|
6
|
+
*
|
|
7
|
+
* This guard:
|
|
8
|
+
* - Reads the token from the Authorization: Bearer header
|
|
9
|
+
* - Verifies its signature with the issuer's published keys, plus issuer,
|
|
10
|
+
* expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm
|
|
11
|
+
* - Maps the token's claims into a normalized AuthContext on request.auth
|
|
12
|
+
*
|
|
13
|
+
* AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc
|
|
14
|
+
* values at runtime (for example a staging and a production tenant).
|
|
15
|
+
*
|
|
16
|
+
* DO NOT EDIT - Changes will be overwritten on next scaffold
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
Injectable,
|
|
20
|
+
CanActivate,
|
|
21
|
+
ExecutionContext,
|
|
22
|
+
UnauthorizedException,
|
|
23
|
+
} from '@nestjs/common';
|
|
24
|
+
import { Reflector } from '@nestjs/core';
|
|
25
|
+
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
|
|
26
|
+
|
|
27
|
+
export const IS_PUBLIC_KEY = 'isPublic';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decorator to mark a route as public (skips authentication)
|
|
31
|
+
* Usage: @Public()
|
|
32
|
+
*/
|
|
33
|
+
export const Public =
|
|
34
|
+
() => (target: any, key?: string, descriptor?: PropertyDescriptor) => {
|
|
35
|
+
Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor?.value || target);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalized auth context attached to authenticated requests.
|
|
40
|
+
* This is what scopeBy and RBAC consume.
|
|
41
|
+
*/
|
|
42
|
+
export interface AuthContext {
|
|
43
|
+
userId?: string;
|
|
44
|
+
email?: string;
|
|
45
|
+
workspaceId?: string;
|
|
46
|
+
organizationId?: string;
|
|
47
|
+
roles: string[];
|
|
48
|
+
claims: JWTPayload;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ISSUER = process.env.AUTH_JWT_ISSUER || <%~ JSON.stringify(it.jwt.issuer) %>;
|
|
52
|
+
const AUDIENCE = process.env.AUTH_JWT_AUDIENCE || <%~ JSON.stringify(it.jwt.audience || '') %>;
|
|
53
|
+
const JWKS_URI = process.env.AUTH_JWKS_URI || <%~ JSON.stringify(it.jwt.jwksUri) %>;
|
|
54
|
+
const ALGORITHMS = <%~ JSON.stringify(it.jwt.algorithms) %>;
|
|
55
|
+
const AUTHORIZED_PARTIES = (process.env.AUTH_JWT_AUTHORIZED_PARTIES || <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>)
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
const CLAIMS = <%~ JSON.stringify(it.jwt.claims) %> as Record<string, string | undefined>;
|
|
60
|
+
|
|
61
|
+
/** A claim by exact name (Auth0 namespaced URLs contain dots), else by dotted path. */
|
|
62
|
+
function claim(payload: JWTPayload, path?: string): unknown {
|
|
63
|
+
if (!path) return undefined;
|
|
64
|
+
if (path in payload) return (payload as any)[path];
|
|
65
|
+
return path.split('.').reduce((v: any, k) => (v == null ? undefined : v[k]), payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const str = (v: unknown): string | undefined =>
|
|
69
|
+
typeof v === 'string' || typeof v === 'number' ? String(v) : undefined;
|
|
70
|
+
|
|
71
|
+
function roles(v: unknown): string[] {
|
|
72
|
+
if (Array.isArray(v)) return v.map(String);
|
|
73
|
+
return typeof v === 'string' && v ? [v] : [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Injectable()
|
|
77
|
+
export class AuthGuard implements CanActivate {
|
|
78
|
+
private readonly jwks = createRemoteJWKSet(new URL(JWKS_URI));
|
|
79
|
+
|
|
80
|
+
constructor(private reflector: Reflector) {}
|
|
81
|
+
|
|
82
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
83
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
84
|
+
context.getHandler(),
|
|
85
|
+
context.getClass(),
|
|
86
|
+
]);
|
|
87
|
+
if (isPublic) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The template's health check stays public for load balancer probes.
|
|
92
|
+
const req = context.switchToHttp().getRequest();
|
|
93
|
+
if (req.method === 'GET' && /^\/health\/?$/.test(req.path)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const request = context.switchToHttp().getRequest();
|
|
98
|
+
const header: string | undefined = request.headers.authorization;
|
|
99
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
100
|
+
throw new UnauthorizedException('No bearer token provided');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let payload: JWTPayload;
|
|
104
|
+
try {
|
|
105
|
+
({ payload } = await jwtVerify(header.substring(7), this.jwks, {
|
|
106
|
+
issuer: ISSUER,
|
|
107
|
+
algorithms: ALGORITHMS,
|
|
108
|
+
<% if (it.jwt.provider !== 'cognito') { %>
|
|
109
|
+
audience: AUDIENCE || undefined,
|
|
110
|
+
<% } %>
|
|
111
|
+
}));
|
|
112
|
+
} catch {
|
|
113
|
+
throw new UnauthorizedException('Invalid token');
|
|
114
|
+
}
|
|
115
|
+
<% if (it.jwt.provider === 'cognito') { %>
|
|
116
|
+
// Cognito ID tokens carry aud; access tokens carry client_id instead.
|
|
117
|
+
if (AUDIENCE) {
|
|
118
|
+
const aud = payload.aud ?? (payload as any).client_id;
|
|
119
|
+
if (!(Array.isArray(aud) ? aud.includes(AUDIENCE) : aud === AUDIENCE)) {
|
|
120
|
+
throw new UnauthorizedException('Invalid token');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
<% } %>
|
|
124
|
+
|
|
125
|
+
const azp = (payload as any).azp;
|
|
126
|
+
if (AUTHORIZED_PARTIES.length > 0 && azp !== undefined && !AUTHORIZED_PARTIES.includes(azp)) {
|
|
127
|
+
throw new UnauthorizedException('Invalid token');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const userId = str(claim(payload, CLAIMS.userId));
|
|
131
|
+
if (!userId) {
|
|
132
|
+
throw new UnauthorizedException('Token has no user id');
|
|
133
|
+
}
|
|
134
|
+
const tenant =
|
|
135
|
+
str(claim(payload, CLAIMS.workspaceId)) ??
|
|
136
|
+
str(claim(payload, CLAIMS.organizationId));
|
|
137
|
+
const auth: AuthContext = {
|
|
138
|
+
userId,
|
|
139
|
+
email: str(claim(payload, CLAIMS.email)),
|
|
140
|
+
workspaceId: tenant,
|
|
141
|
+
organizationId: tenant,
|
|
142
|
+
roles: roles(claim(payload, CLAIMS.roles)),
|
|
143
|
+
claims: payload,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
request.auth = auth;
|
|
147
|
+
request.user = { id: userId, ...auth };
|
|
148
|
+
// Read by ScopeGuard's scope lookup.
|
|
149
|
+
request.workspaceId = tenant;
|
|
150
|
+
request.organizationId = tenant;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Helper function to get AuthContext from request
|
|
157
|
+
* Use this in controllers/services to access the authenticated context
|
|
158
|
+
*/
|
|
159
|
+
export function getAuthContext(request: any): AuthContext | undefined {
|
|
160
|
+
return request?.auth;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Helper function to require AuthContext (throws if not present)
|
|
165
|
+
*/
|
|
166
|
+
export function requireAuthContext(request: any): AuthContext {
|
|
167
|
+
const ctx = getAuthContext(request);
|
|
168
|
+
if (!ctx) {
|
|
169
|
+
throw new UnauthorizedException('Authentication required');
|
|
170
|
+
}
|
|
171
|
+
return ctx;
|
|
172
|
+
}
|
|
@@ -28,7 +28,7 @@ export const SkipScopeCheck = () =>
|
|
|
28
28
|
);
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
<% if (it.
|
|
31
|
+
<% if (it.hasAuth) { %>
|
|
32
32
|
/**
|
|
33
33
|
* Import Public decorator from auth.guard to avoid duplicate exports
|
|
34
34
|
*/
|
|
@@ -246,7 +246,11 @@ export class ScopeGuard implements CanActivate {
|
|
|
246
246
|
for (const scope of scopeConfig.scopes) {
|
|
247
247
|
const expectedValue = this.getScopeValue(request, scope.contextKey);
|
|
248
248
|
if (!expectedValue) {
|
|
249
|
+
<% if (it.hasAuth) { %>
|
|
250
|
+
return false; // The caller has no tenant, so it owns no scoped rows
|
|
251
|
+
<% } else { %>
|
|
249
252
|
continue; // No scope value in context, skip this check
|
|
253
|
+
<% } %>
|
|
250
254
|
}
|
|
251
255
|
|
|
252
256
|
let actualValue: any;
|
|
@@ -257,7 +261,8 @@ export class ScopeGuard implements CanActivate {
|
|
|
257
261
|
actualValue = this.getNestedValue(entity, scope.path);
|
|
258
262
|
}
|
|
259
263
|
|
|
260
|
-
|
|
264
|
+
// Token claims are strings; a numeric FK column reads back as a number.
|
|
265
|
+
if (actualValue == null || String(actualValue) !== String(expectedValue)) {
|
|
261
266
|
return false;
|
|
262
267
|
}
|
|
263
268
|
}
|
|
@@ -277,9 +282,17 @@ export class ScopeGuard implements CanActivate {
|
|
|
277
282
|
for (const scope of scopeConfig.scopes) {
|
|
278
283
|
if (scope.direct) {
|
|
279
284
|
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
285
|
+
<% if (it.hasAuth) { %>
|
|
286
|
+
if (!scopeValue) {
|
|
287
|
+
throw new ForbiddenException('No tenant in the auth context');
|
|
288
|
+
}
|
|
289
|
+
// Always the caller's tenant, never one from the body.
|
|
290
|
+
request.body[scope.field] = scopeValue;
|
|
291
|
+
<% } else { %>
|
|
280
292
|
if (scopeValue && !request.body[scope.field]) {
|
|
281
293
|
request.body[scope.field] = scopeValue;
|
|
282
294
|
}
|
|
295
|
+
<% } %>
|
|
283
296
|
}
|
|
284
297
|
}
|
|
285
298
|
}
|
|
@@ -292,6 +305,11 @@ export class ScopeGuard implements CanActivate {
|
|
|
292
305
|
for (const scope of scopeConfig.scopes) {
|
|
293
306
|
if (scope.direct) {
|
|
294
307
|
const scopeValue = this.getScopeValue(request, scope.contextKey);
|
|
308
|
+
<% if (it.hasAuth) { %>
|
|
309
|
+
if (!scopeValue) {
|
|
310
|
+
throw new ForbiddenException('No tenant in the auth context');
|
|
311
|
+
}
|
|
312
|
+
<% } %>
|
|
295
313
|
if (scopeValue) {
|
|
296
314
|
// Add filter for @apso/crud
|
|
297
315
|
request.query[`filter.${scope.field}`] = `$eq:${scopeValue}`;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<%~ includeFile('./header.eta', it) %>
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Authentication for every route on the generated /api router.
|
|
5
|
+
|
|
6
|
+
<% if (it.jwt) { %>
|
|
7
|
+
Verifies <%= it.jwt.provider %> JWTs from the Authorization: Bearer header against the
|
|
8
|
+
issuer's published keys (JWKS), checks issuer, expiry<% if (it.jwt.audience) { %>, audience<% } %> and algorithm,
|
|
9
|
+
and maps the token's claims into an AuthContext on request.state.auth.
|
|
10
|
+
AUTH_JWT_ISSUER, AUTH_JWT_AUDIENCE and AUTH_JWKS_URI override the .apsorc values.
|
|
11
|
+
<% } else { %>
|
|
12
|
+
Reads an API key from the <%= it.apiKey.apiKeyHeader %> header and looks up the <%= it.apiKey.apiKeyEntity %> row
|
|
13
|
+
whose <%= it.apiKey.keyField %> is the key's hex SHA-256.
|
|
14
|
+
<% } %>
|
|
15
|
+
"""
|
|
16
|
+
<% if (it.apiKey) { %>
|
|
17
|
+
import hashlib
|
|
18
|
+
from datetime import date, datetime, timezone
|
|
19
|
+
<% } else { %>
|
|
20
|
+
import os
|
|
21
|
+
<% } %>
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, List, Optional
|
|
24
|
+
|
|
25
|
+
<% if (it.jwt) { %>
|
|
26
|
+
import jwt
|
|
27
|
+
from fastapi import HTTPException, Request, status
|
|
28
|
+
from jwt import PyJWKClient
|
|
29
|
+
from starlette.concurrency import run_in_threadpool
|
|
30
|
+
<% } else { %>
|
|
31
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
32
|
+
from sqlalchemy import select
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
34
|
+
|
|
35
|
+
from app.database import get_db
|
|
36
|
+
from .models.<%= it.apiKey.pyModule %> import <%= it.apiKey.apiKeyEntity %>
|
|
37
|
+
<% } %>
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AuthContext:
|
|
42
|
+
"""The normalized identity of the caller."""
|
|
43
|
+
|
|
44
|
+
user_id: Optional[str] = None
|
|
45
|
+
email: Optional[str] = None
|
|
46
|
+
service_id: Optional[str] = None
|
|
47
|
+
workspace_id: Optional[str] = None
|
|
48
|
+
organization_id: Optional[str] = None
|
|
49
|
+
roles: List[str] = field(default_factory=list)
|
|
50
|
+
claims: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_auth_context(request: Request) -> Optional[AuthContext]:
|
|
54
|
+
"""The authenticated caller, if any."""
|
|
55
|
+
return getattr(request.state, "auth", None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _unauthorized(detail: str) -> HTTPException:
|
|
59
|
+
return HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
|
|
60
|
+
<% if (it.jwt) { %>
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
ISSUER = os.getenv("AUTH_JWT_ISSUER") or <%~ JSON.stringify(it.jwt.issuer) %>
|
|
64
|
+
AUDIENCE = os.getenv("AUTH_JWT_AUDIENCE") or <%~ JSON.stringify(it.jwt.audience || '') %>
|
|
65
|
+
JWKS_URI = os.getenv("AUTH_JWKS_URI") or <%~ JSON.stringify(it.jwt.jwksUri) %>
|
|
66
|
+
ALGORITHMS = <%~ JSON.stringify(it.jwt.algorithms) %>
|
|
67
|
+
# Origins allowed in the azp claim; empty allows any.
|
|
68
|
+
AUTHORIZED_PARTIES = [
|
|
69
|
+
p.strip()
|
|
70
|
+
for p in (os.getenv("AUTH_JWT_AUTHORIZED_PARTIES") or <%~ JSON.stringify(it.jwt.authorizedParties.join(',')) %>).split(",")
|
|
71
|
+
if p.strip()
|
|
72
|
+
]
|
|
73
|
+
CLAIMS = <%~ JSON.stringify(it.jwt.claims) %>
|
|
74
|
+
|
|
75
|
+
# Fetches and caches the issuer's keys on first use.
|
|
76
|
+
_jwks = PyJWKClient(JWKS_URI, cache_keys=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _claim(payload: dict, path: Optional[str]) -> Any:
|
|
80
|
+
"""A claim by exact name (Auth0 namespaced URLs contain dots), else by dotted path."""
|
|
81
|
+
if not path:
|
|
82
|
+
return None
|
|
83
|
+
if path in payload:
|
|
84
|
+
return payload[path]
|
|
85
|
+
value: Any = payload
|
|
86
|
+
for part in path.split("."):
|
|
87
|
+
if not isinstance(value, dict):
|
|
88
|
+
return None
|
|
89
|
+
value = value.get(part)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _str(value: Any) -> Optional[str]:
|
|
94
|
+
if isinstance(value, bool):
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(value, (str, int, float)):
|
|
97
|
+
return str(value)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _roles(value: Any) -> List[str]:
|
|
102
|
+
if isinstance(value, list):
|
|
103
|
+
return [str(v) for v in value]
|
|
104
|
+
return [value] if isinstance(value, str) and value else []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def require_auth(request: Request) -> AuthContext:
|
|
108
|
+
"""FastAPI dependency: verify the bearer token and attach the AuthContext."""
|
|
109
|
+
header = request.headers.get("authorization", "")
|
|
110
|
+
if not header.startswith("Bearer "):
|
|
111
|
+
raise _unauthorized("No bearer token provided")
|
|
112
|
+
token = header[len("Bearer "):]
|
|
113
|
+
try:
|
|
114
|
+
signing_key = await run_in_threadpool(_jwks.get_signing_key_from_jwt, token)
|
|
115
|
+
except jwt.PyJWKClientConnectionError:
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
118
|
+
detail="Token signing keys unavailable",
|
|
119
|
+
)
|
|
120
|
+
except jwt.PyJWTError:
|
|
121
|
+
raise _unauthorized("Invalid token")
|
|
122
|
+
try:
|
|
123
|
+
payload = jwt.decode(
|
|
124
|
+
token,
|
|
125
|
+
signing_key.key,
|
|
126
|
+
algorithms=ALGORITHMS,
|
|
127
|
+
issuer=ISSUER,
|
|
128
|
+
<% if (it.jwt.provider !== 'cognito') { %>
|
|
129
|
+
audience=AUDIENCE or None,
|
|
130
|
+
options={"require": ["exp", "iss"], "verify_aud": bool(AUDIENCE)},
|
|
131
|
+
<% } else { %>
|
|
132
|
+
options={"require": ["exp", "iss"], "verify_aud": False},
|
|
133
|
+
<% } %>
|
|
134
|
+
)
|
|
135
|
+
except jwt.PyJWTError:
|
|
136
|
+
raise _unauthorized("Invalid token")
|
|
137
|
+
<% if (it.jwt.provider === 'cognito') { %>
|
|
138
|
+
# Cognito ID tokens carry aud; access tokens carry client_id instead.
|
|
139
|
+
if AUDIENCE:
|
|
140
|
+
aud = payload.get("aud", payload.get("client_id"))
|
|
141
|
+
if not (AUDIENCE in aud if isinstance(aud, list) else aud == AUDIENCE):
|
|
142
|
+
raise _unauthorized("Invalid token")
|
|
143
|
+
<% } %>
|
|
144
|
+
|
|
145
|
+
azp = payload.get("azp")
|
|
146
|
+
if AUTHORIZED_PARTIES and azp is not None and azp not in AUTHORIZED_PARTIES:
|
|
147
|
+
raise _unauthorized("Invalid token")
|
|
148
|
+
|
|
149
|
+
user_id = _str(_claim(payload, CLAIMS.get("userId")))
|
|
150
|
+
if not user_id:
|
|
151
|
+
raise _unauthorized("Token has no user id")
|
|
152
|
+
tenant = _str(_claim(payload, CLAIMS.get("workspaceId"))) or _str(
|
|
153
|
+
_claim(payload, CLAIMS.get("organizationId"))
|
|
154
|
+
)
|
|
155
|
+
ctx = AuthContext(
|
|
156
|
+
user_id=user_id,
|
|
157
|
+
email=_str(_claim(payload, CLAIMS.get("email"))),
|
|
158
|
+
workspace_id=tenant,
|
|
159
|
+
organization_id=tenant,
|
|
160
|
+
roles=_roles(_claim(payload, CLAIMS.get("roles"))),
|
|
161
|
+
claims=payload,
|
|
162
|
+
)
|
|
163
|
+
request.state.auth = ctx
|
|
164
|
+
return ctx
|
|
165
|
+
<% } else { %>
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _expired(value: Any) -> bool:
|
|
169
|
+
if value is None:
|
|
170
|
+
return False
|
|
171
|
+
if isinstance(value, datetime):
|
|
172
|
+
if value.tzinfo is None:
|
|
173
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
174
|
+
return value <= datetime.now(timezone.utc)
|
|
175
|
+
if isinstance(value, date):
|
|
176
|
+
return value <= date.today()
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def require_auth(request: Request, db: AsyncSession = Depends(get_db)) -> AuthContext:
|
|
181
|
+
"""FastAPI dependency: authenticate the API key and attach the AuthContext."""
|
|
182
|
+
key = request.headers.get(<%~ JSON.stringify(it.apiKey.apiKeyHeader) %>)
|
|
183
|
+
if not key:
|
|
184
|
+
raise _unauthorized("No API key provided")
|
|
185
|
+
digest = hashlib.sha256(key.encode()).hexdigest()
|
|
186
|
+
result = await db.execute(
|
|
187
|
+
select(<%= it.apiKey.apiKeyEntity %>).where(<%= it.apiKey.apiKeyEntity %>.<%= it.apiKey.keyField %> == digest)
|
|
188
|
+
)
|
|
189
|
+
record = result.scalar_one_or_none()
|
|
190
|
+
if record is None:
|
|
191
|
+
raise _unauthorized("Invalid API key")
|
|
192
|
+
<% if (it.apiKey.hasStatus) { %>
|
|
193
|
+
if getattr(record.status, "value", record.status) != "active":
|
|
194
|
+
raise _unauthorized("API key is not active")
|
|
195
|
+
<% } %>
|
|
196
|
+
<% if (it.apiKey.hasExpiresAt) { %>
|
|
197
|
+
if _expired(record.expiresAt):
|
|
198
|
+
raise _unauthorized("API key has expired")
|
|
199
|
+
<% } %>
|
|
200
|
+
|
|
201
|
+
tenant = getattr(record, <%~ JSON.stringify(it.apiKey.workspaceField) %>, None)
|
|
202
|
+
tenant = str(tenant) if tenant is not None else None
|
|
203
|
+
<% if (it.apiKey.roleField) { %>
|
|
204
|
+
role = getattr(record, <%~ JSON.stringify(it.apiKey.roleField) %>, None)
|
|
205
|
+
roles = [str(getattr(role, "value", role))] if role is not None else []
|
|
206
|
+
<% } else { %>
|
|
207
|
+
roles: List[str] = []
|
|
208
|
+
<% } %>
|
|
209
|
+
ctx = AuthContext(
|
|
210
|
+
service_id=str(record.id),
|
|
211
|
+
workspace_id=tenant,
|
|
212
|
+
organization_id=tenant,
|
|
213
|
+
roles=roles,
|
|
214
|
+
)
|
|
215
|
+
request.state.auth = ctx
|
|
216
|
+
return ctx
|
|
217
|
+
<% } %>
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
<%~ includeFile('./header.eta', it) %>
|
|
2
2
|
|
|
3
|
+
<% if (it.hasAuth) { %>
|
|
4
|
+
from fastapi import APIRouter, Depends
|
|
5
|
+
|
|
6
|
+
from .auth import require_auth
|
|
7
|
+
<% } else { %>
|
|
3
8
|
from fastapi import APIRouter
|
|
9
|
+
<% } %>
|
|
4
10
|
|
|
5
11
|
<% it.entities.forEach((entity) => { %>
|
|
6
12
|
from .routers.<%= entity.name.toLowerCase() %> import router as <%= entity.name.toLowerCase() %>_router
|
|
7
13
|
<% }) %>
|
|
8
14
|
|
|
9
15
|
# Create main router for autogenerated endpoints
|
|
16
|
+
<% if (it.hasAuth) { %>
|
|
17
|
+
# Every route on this router requires authentication (.apsorc auth).
|
|
18
|
+
autogen_router = APIRouter(prefix="/api", dependencies=[Depends(require_auth)])
|
|
19
|
+
<% } else { %>
|
|
10
20
|
autogen_router = APIRouter(prefix="/api")
|
|
21
|
+
<% } %>
|
|
11
22
|
|
|
12
23
|
# Include all entity routers
|
|
13
24
|
<% it.entities.forEach((entity) => { %>
|
|
@@ -10,7 +10,7 @@ const pgEnumName = (it.entityName + '_' + it.field.name + '_enum').toLowerCase()
|
|
|
10
10
|
enums.<%= enumTypeName %>,
|
|
11
11
|
name="<%= pgEnumName %>",
|
|
12
12
|
values_callable=lambda x: [e.value for e in x],
|
|
13
|
-
create_type=
|
|
13
|
+
create_type=True
|
|
14
14
|
),
|
|
15
15
|
nullable=<%= it.field.nullable === false ? 'False' : 'True' %>
|
|
16
16
|
)
|
|
@@ -5,12 +5,23 @@ from typing import Optional, List, Any
|
|
|
5
5
|
from datetime import datetime, date
|
|
6
6
|
from decimal import Decimal
|
|
7
7
|
from uuid import UUID
|
|
8
|
-
from pydantic import BaseModel, Field, ConfigDict
|
|
8
|
+
from pydantic import BaseModel, Field, ConfigDict, field_serializer
|
|
9
|
+
from sqlalchemy import inspect as sa_inspect
|
|
9
10
|
<% if (it.importEnums) { -%>
|
|
10
11
|
from ..models import enums
|
|
11
12
|
<% } -%>
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
|
|
16
|
+
def _columns(value: Any) -> Any:
|
|
17
|
+
"""A related ORM row as its column values; its own relations are not loaded."""
|
|
18
|
+
if isinstance(value, list):
|
|
19
|
+
return [_columns(v) for v in value]
|
|
20
|
+
if not hasattr(value, "__table__"):
|
|
21
|
+
return value
|
|
22
|
+
return {a.key: getattr(value, a.key) for a in sa_inspect(value).mapper.column_attrs}
|
|
23
|
+
|
|
24
|
+
|
|
14
25
|
class <%= it.entityName %>Base(BaseModel):
|
|
15
26
|
"""Base schema with common fields for <%= it.entityName %>"""
|
|
16
27
|
<% it.columns.filter(c => !['id', 'createdAt', 'updatedAt', 'created_at', 'updated_at'].includes(c.name)).forEach((field) => { -%>
|
|
@@ -76,6 +87,16 @@ class <%= it.entityName %>(BaseModel):
|
|
|
76
87
|
<% it.associations.filter(a => a.type === 'ManyToOne' || a.type === 'OneToOne').forEach((assoc) => { -%>
|
|
77
88
|
<%= assoc.relationshipName %>: Optional[Any] = None
|
|
78
89
|
<% }); -%>
|
|
90
|
+
<% const relationFields = [
|
|
91
|
+
...it.associations.filter(a => a.type === 'OneToMany' || a.type === 'ManyToMany').map(a => a.pluralizedRelationshipName),
|
|
92
|
+
...it.associations.filter(a => a.type === 'ManyToOne' || a.type === 'OneToOne').map(a => a.relationshipName),
|
|
93
|
+
]; -%>
|
|
94
|
+
<% if (relationFields.length > 0) { -%>
|
|
95
|
+
|
|
96
|
+
@field_serializer(<%~ [...new Set(relationFields)].map(n => JSON.stringify(n)).join(', ') %>)
|
|
97
|
+
def _serialize_relations(self, value: Any) -> Any:
|
|
98
|
+
return _columns(value)
|
|
99
|
+
<% } -%>
|
|
79
100
|
|
|
80
101
|
|
|
81
102
|
class <%= it.entityName %>List(BaseModel):
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%~ includeFile('./header.eta') %>
|
|
2
2
|
|
|
3
3
|
import { Controller, Body } from "@nestjs/common";
|
|
4
|
-
import { Crud, CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
|
|
4
|
+
import { Crud, <% if (it.crudAuth.length) { %>CrudAuth, <% } %>CrudController, Override, ParsedRequest, CrudRequest, ParsedBody, CreateManyDto } from '@apso/crud';
|
|
5
5
|
import { ApiTags, ApiResponse, ApiOperation, ApiBody } from '@nestjs/swagger';
|
|
6
6
|
import {<%= it.entityName %>, <%= it.entityName %>Create, <%= it.entityName %>Update} from './dtos/<%= it.entityName%>.dto'
|
|
7
7
|
import {<%= it.svcName %>} from './<%= it.entityName %>.service';
|
|
@@ -51,6 +51,21 @@ import {<%= it.svcName %>} from './<%= it.entityName %>.service';
|
|
|
51
51
|
<% } %>
|
|
52
52
|
}
|
|
53
53
|
})
|
|
54
|
+
<% if (it.crudAuth.length) { %>
|
|
55
|
+
// Every query and write is limited to the caller's tenant (.apsorc scopeBy).
|
|
56
|
+
@CrudAuth({
|
|
57
|
+
filter: (req: any) => ({
|
|
58
|
+
<% it.crudAuth.forEach((s) => { %>
|
|
59
|
+
<%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
|
|
60
|
+
<% }) %>
|
|
61
|
+
}),
|
|
62
|
+
persist: (req: any) => ({
|
|
63
|
+
<% it.crudAuth.forEach((s) => { %>
|
|
64
|
+
<%= s.field %>: req.<%= s.contextKey %> ?? req.user?.<%= s.contextKey %>,
|
|
65
|
+
<% }) %>
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
<% } %>
|
|
54
69
|
|
|
55
70
|
@Controller('<%= it.pluralEntityName %>')
|
|
56
71
|
@ApiTags('<%= it.pluralEntityName %>')
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
<% it.entities.forEach((entity) => { %>
|
|
4
4
|
import {<%= entity.name %>Module} from './<%= entity.name %>/<%= entity.name %>.module'
|
|
5
5
|
<%}) %>
|
|
6
|
+
<% if (it.hasGuards) { %>
|
|
7
|
+
import { GuardsModule } from './guards/guards.module'
|
|
8
|
+
<% } %>
|
|
6
9
|
|
|
7
10
|
export default [
|
|
8
11
|
<% it.entities.forEach((entity) => { %>
|
|
9
12
|
<%= entity.name %>Module,
|
|
10
13
|
<%}) %>
|
|
14
|
+
<% if (it.hasGuards) { %>
|
|
15
|
+
GuardsModule,
|
|
16
|
+
<% } %>
|
|
11
17
|
]
|
package/dist/lib/types/auth.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Supported authentication provider types
|
|
3
3
|
*/
|
|
4
|
-
export type AuthProviderType = "better-auth" | "custom-db-session" | "auth0" | "clerk" | "cognito" | "api-key";
|
|
4
|
+
export type AuthProviderType = "better-auth" | "custom-db-session" | "auth0" | "clerk" | "cognito" | "jwt" | "api-key";
|
|
5
5
|
/**
|
|
6
6
|
* Base configuration common to all auth providers
|
|
7
7
|
*/
|
|
@@ -50,17 +50,25 @@ export interface JwtConfig {
|
|
|
50
50
|
*/
|
|
51
51
|
issuer: string;
|
|
52
52
|
/**
|
|
53
|
-
* Expected audience for the JWT
|
|
53
|
+
* Expected audience for the JWT. When omitted the audience is not checked
|
|
54
|
+
* (Clerk session tokens and Cognito access tokens carry no `aud` claim).
|
|
55
|
+
* For Cognito an access token's `client_id` is checked against it instead.
|
|
54
56
|
*/
|
|
55
|
-
audience
|
|
57
|
+
audience?: string;
|
|
56
58
|
/**
|
|
57
59
|
* JWKS URI for key rotation. If not provided, uses issuer + /.well-known/jwks.json
|
|
58
60
|
*/
|
|
59
61
|
jwksUri?: string;
|
|
60
62
|
/**
|
|
61
|
-
* Algorithms to accept. Default: ["RS256"]
|
|
63
|
+
* Algorithms to accept. Default: ["RS256"]. Better Auth's JWT plugin signs
|
|
64
|
+
* with EdDSA by default.
|
|
62
65
|
*/
|
|
63
66
|
algorithms?: string[];
|
|
67
|
+
/**
|
|
68
|
+
* Origins allowed in the token's `azp` claim (Clerk recommends this). A
|
|
69
|
+
* token whose azp is not listed is rejected; one without azp is accepted.
|
|
70
|
+
*/
|
|
71
|
+
authorizedParties?: string[];
|
|
64
72
|
}
|
|
65
73
|
/**
|
|
66
74
|
* Claim mapping for JWT providers
|
|
@@ -88,10 +96,11 @@ export interface JwtClaimsMapping {
|
|
|
88
96
|
email?: string;
|
|
89
97
|
}
|
|
90
98
|
/**
|
|
91
|
-
* Configuration for JWT-based providers (Auth0, Cognito,
|
|
99
|
+
* Configuration for JWT-based providers (Auth0, Clerk, Cognito, or any issuer
|
|
100
|
+
* that publishes a JWKS via the generic "jwt" provider)
|
|
92
101
|
*/
|
|
93
102
|
export interface JwtAuthConfig extends BaseAuthConfig {
|
|
94
|
-
provider: "auth0" | "clerk" | "cognito";
|
|
103
|
+
provider: "auth0" | "clerk" | "cognito" | "jwt";
|
|
95
104
|
/**
|
|
96
105
|
* JWT verification configuration
|
|
97
106
|
*/
|
|
@@ -119,7 +128,12 @@ export interface ApiKeyAuthConfig extends BaseAuthConfig {
|
|
|
119
128
|
*/
|
|
120
129
|
apiKeyEntity?: string;
|
|
121
130
|
/**
|
|
122
|
-
* Field
|
|
131
|
+
* Field on apiKeyEntity holding the hex SHA-256 of the key. Default: "keyHash"
|
|
132
|
+
*/
|
|
133
|
+
keyField?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Field name on apiKeyEntity for workspace/organization ID.
|
|
136
|
+
* Default: "organizationId"
|
|
123
137
|
*/
|
|
124
138
|
workspaceField?: string;
|
|
125
139
|
/**
|
|
@@ -201,3 +215,23 @@ export declare function isJwtAuth(auth: AuthConfig): auth is JwtAuthConfig;
|
|
|
201
215
|
* @returns {boolean} True if the auth config is API key based
|
|
202
216
|
*/
|
|
203
217
|
export declare function isApiKeyAuth(auth: AuthConfig): auth is ApiKeyAuthConfig;
|
|
218
|
+
/** A JWT auth config with every default filled in, as the templates use it. */
|
|
219
|
+
export interface ResolvedJwtAuth {
|
|
220
|
+
provider: JwtAuthConfig["provider"];
|
|
221
|
+
issuer: string;
|
|
222
|
+
audience?: string;
|
|
223
|
+
jwksUri: string;
|
|
224
|
+
algorithms: string[];
|
|
225
|
+
authorizedParties: string[];
|
|
226
|
+
claims: Required<Pick<JwtClaimsMapping, "userId">> & Omit<JwtClaimsMapping, "userId">;
|
|
227
|
+
}
|
|
228
|
+
/** An API key auth config with every default filled in. */
|
|
229
|
+
export type ResolvedApiKeyAuth = Required<Omit<ApiKeyAuthConfig, "roleField">> & Pick<ApiKeyAuthConfig, "roleField">;
|
|
230
|
+
/**
|
|
231
|
+
* Fill JWT defaults and reject configs that would generate an unsafe check.
|
|
232
|
+
* Only asymmetric algorithms are accepted: the key comes from the issuer's
|
|
233
|
+
* JWKS, so there is no shared secret to configure or leak.
|
|
234
|
+
*/
|
|
235
|
+
export declare function resolveJwtAuth(auth: JwtAuthConfig): ResolvedJwtAuth;
|
|
236
|
+
/** Fill API key defaults. */
|
|
237
|
+
export declare function resolveApiKeyAuth(auth: ApiKeyAuthConfig): ResolvedApiKeyAuth;
|