@pikku/openapi-parser 0.12.12 → 0.12.14
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/CHANGELOG.md +12 -0
- package/dist/auth-config.d.ts +75 -0
- package/dist/auth-config.js +81 -0
- package/dist/codegen.d.ts +3 -0
- package/dist/codegen.js +193 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/auth-config.ts +94 -0
- package/src/codegen.test.ts +120 -0
- package/src/codegen.ts +247 -7
- package/src/index.ts +9 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @pikku/openapi-parser
|
|
2
2
|
|
|
3
|
+
## 0.12.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a10e88d: auth-config: new `extraHeaders` field — static headers baked into every generated request (the delegated login call and all proxied service calls), for upstreams that route on a header such as multi-tenant APIs resolving the tenant from `Origin`.
|
|
8
|
+
|
|
9
|
+
## 0.12.13
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 0f3edd3: Support an operator-supplied auth config that overrides/augments a spec's securitySchemes: custom auth header name/format (e.g. a raw token in `authentication:` instead of `Authorization: Bearer`), and a delegated-login descriptor (login path, credential fields, token dot-path, claims mapping from the decoded JWT payload or response body) that emits a self-contained `src/<name>-upstream-auth.ts` `authenticate<Name>Upstream()` for wiring into `@pikku/better-auth`'s `delegatedAuth()` plugin.
|
|
14
|
+
|
|
3
15
|
## 0.12.12
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const DelegatedClaimsSchema: z.ZodObject<{
|
|
3
|
+
source: z.ZodEnum<{
|
|
4
|
+
jwt: "jwt";
|
|
5
|
+
response: "response";
|
|
6
|
+
}>;
|
|
7
|
+
externalId: z.ZodString;
|
|
8
|
+
email: z.ZodString;
|
|
9
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
10
|
+
role: z.ZodOptional<z.ZodString>;
|
|
11
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export declare const DelegatedLoginSchema: z.ZodObject<{
|
|
14
|
+
loginPath: z.ZodString;
|
|
15
|
+
loginMethod: z.ZodDefault<z.ZodString>;
|
|
16
|
+
credentials: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
17
|
+
email: "email";
|
|
18
|
+
password: "password";
|
|
19
|
+
apiKey: "apiKey";
|
|
20
|
+
}>>>;
|
|
21
|
+
apiKeyHeader: z.ZodDefault<z.ZodString>;
|
|
22
|
+
tokenPath: z.ZodString;
|
|
23
|
+
expiresAtPath: z.ZodOptional<z.ZodString>;
|
|
24
|
+
claims: z.ZodObject<{
|
|
25
|
+
source: z.ZodEnum<{
|
|
26
|
+
jwt: "jwt";
|
|
27
|
+
response: "response";
|
|
28
|
+
}>;
|
|
29
|
+
externalId: z.ZodString;
|
|
30
|
+
email: z.ZodString;
|
|
31
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
32
|
+
role: z.ZodOptional<z.ZodString>;
|
|
33
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export declare const AuthConfigSchema: z.ZodObject<{
|
|
37
|
+
headerName: z.ZodOptional<z.ZodString>;
|
|
38
|
+
headerFormat: z.ZodOptional<z.ZodEnum<{
|
|
39
|
+
raw: "raw";
|
|
40
|
+
bearer: "bearer";
|
|
41
|
+
}>>;
|
|
42
|
+
extraHeaders: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
43
|
+
delegated: z.ZodOptional<z.ZodObject<{
|
|
44
|
+
loginPath: z.ZodString;
|
|
45
|
+
loginMethod: z.ZodDefault<z.ZodString>;
|
|
46
|
+
credentials: z.ZodDefault<z.ZodArray<z.ZodEnum<{
|
|
47
|
+
email: "email";
|
|
48
|
+
password: "password";
|
|
49
|
+
apiKey: "apiKey";
|
|
50
|
+
}>>>;
|
|
51
|
+
apiKeyHeader: z.ZodDefault<z.ZodString>;
|
|
52
|
+
tokenPath: z.ZodString;
|
|
53
|
+
expiresAtPath: z.ZodOptional<z.ZodString>;
|
|
54
|
+
claims: z.ZodObject<{
|
|
55
|
+
source: z.ZodEnum<{
|
|
56
|
+
jwt: "jwt";
|
|
57
|
+
response: "response";
|
|
58
|
+
}>;
|
|
59
|
+
externalId: z.ZodString;
|
|
60
|
+
email: z.ZodString;
|
|
61
|
+
name: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
|
|
62
|
+
role: z.ZodOptional<z.ZodString>;
|
|
63
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
64
|
+
}, z.core.$strip>;
|
|
65
|
+
}, z.core.$strip>>;
|
|
66
|
+
}, z.core.$strip>;
|
|
67
|
+
export type DelegatedLoginConfig = z.infer<typeof DelegatedLoginSchema>;
|
|
68
|
+
export type AuthConfig = z.infer<typeof AuthConfigSchema>;
|
|
69
|
+
/** The effective value template for the auth header. */
|
|
70
|
+
export declare const authHeaderValue: (config: AuthConfig, tokenExpr: string) => {
|
|
71
|
+
header: string;
|
|
72
|
+
value: string;
|
|
73
|
+
};
|
|
74
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
75
|
+
export declare function loadAuthConfig(filePath: string): Promise<AuthConfig>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator/agent-supplied auth configuration that overrides or augments an
|
|
3
|
+
* OpenAPI spec's securitySchemes. Needed for real-world enterprise specs that
|
|
4
|
+
* carry no machine-readable auth at all (auth described only in prose), or
|
|
5
|
+
* that use a custom auth header the spec doesn't declare.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'fs/promises';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
export const DelegatedClaimsSchema = z.object({
|
|
10
|
+
/** Where identity claims live: the decoded login-JWT payload, or the login response body. */
|
|
11
|
+
source: z.enum(['jwt', 'response']),
|
|
12
|
+
/** Dot-path to the stable upstream user id (e.g. 'user._id'). */
|
|
13
|
+
externalId: z.string(),
|
|
14
|
+
email: z.string(),
|
|
15
|
+
/** One dot-path, or several joined with a space (e.g. first + last name). */
|
|
16
|
+
name: z.union([z.string(), z.array(z.string())]).optional(),
|
|
17
|
+
role: z.string().optional(),
|
|
18
|
+
tenantId: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
export const DelegatedLoginSchema = z.object({
|
|
21
|
+
/** Spec-relative path of the login operation (e.g. '/users/login'). */
|
|
22
|
+
loginPath: z.string(),
|
|
23
|
+
loginMethod: z.string().default('post'),
|
|
24
|
+
/** Which credential fields the login accepts. email/password go in the JSON body; apiKey in a header. */
|
|
25
|
+
credentials: z
|
|
26
|
+
.array(z.enum(['email', 'password', 'apiKey']))
|
|
27
|
+
.default(['email', 'password']),
|
|
28
|
+
/** Header carrying the apiKey when 'apiKey' is a supported credential. */
|
|
29
|
+
apiKeyHeader: z.string().default('x-api-key'),
|
|
30
|
+
/** Dot-path to the token in the login response (e.g. 'token'). */
|
|
31
|
+
tokenPath: z.string(),
|
|
32
|
+
/** Dot-path to an epoch-seconds expiry in the response; defaults to the JWT `exp` claim when source is jwt. */
|
|
33
|
+
expiresAtPath: z.string().optional(),
|
|
34
|
+
claims: DelegatedClaimsSchema,
|
|
35
|
+
});
|
|
36
|
+
export const AuthConfigSchema = z.object({
|
|
37
|
+
/** Auth header the API expects on authenticated calls (e.g. 'authentication'). Overrides the spec. */
|
|
38
|
+
headerName: z.string().optional(),
|
|
39
|
+
/** 'raw' sends the bare token; 'bearer' prefixes it. Default: bearer for Authorization, raw for custom headers. */
|
|
40
|
+
headerFormat: z.enum(['raw', 'bearer']).optional(),
|
|
41
|
+
/**
|
|
42
|
+
* Static headers sent on EVERY request — the delegated login call and all
|
|
43
|
+
* proxied API calls. Needed for upstreams that route on a header, e.g. a
|
|
44
|
+
* multi-tenant API that resolves the tenant from an `Origin` header and
|
|
45
|
+
* rejects requests without it.
|
|
46
|
+
*/
|
|
47
|
+
extraHeaders: z.record(z.string(), z.string()).optional(),
|
|
48
|
+
/** Present when end-users sign in with their existing upstream credentials (delegated login). */
|
|
49
|
+
delegated: DelegatedLoginSchema.optional(),
|
|
50
|
+
});
|
|
51
|
+
/** The effective value template for the auth header. */
|
|
52
|
+
export const authHeaderValue = (config, tokenExpr) => {
|
|
53
|
+
const header = config.headerName ?? 'Authorization';
|
|
54
|
+
const format = config.headerFormat ??
|
|
55
|
+
(config.headerName && config.headerName !== 'Authorization'
|
|
56
|
+
? 'raw'
|
|
57
|
+
: 'bearer');
|
|
58
|
+
return {
|
|
59
|
+
header,
|
|
60
|
+
value: format === 'bearer' ? `\`Bearer \${${tokenExpr}}\`` : tokenExpr,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
64
|
+
export async function loadAuthConfig(filePath) {
|
|
65
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(raw);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw new Error(`Auth config ${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
const result = AuthConfigSchema.safeParse(parsed);
|
|
74
|
+
if (!result.success) {
|
|
75
|
+
const issues = result.error.issues
|
|
76
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
throw new Error(`Auth config ${filePath} is invalid:\n${issues}`);
|
|
79
|
+
}
|
|
80
|
+
return result.data;
|
|
81
|
+
}
|
package/dist/codegen.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Returns Record<string, string> compatible with getAddonFiles output.
|
|
4
4
|
*/
|
|
5
5
|
import type { ParsedSpec } from './parse-openapi.js';
|
|
6
|
+
import { type AuthConfig } from './auth-config.js';
|
|
6
7
|
interface AddonVars {
|
|
7
8
|
name: string;
|
|
8
9
|
camelName: string;
|
|
@@ -17,6 +18,8 @@ interface CodegenFlags {
|
|
|
17
18
|
credential?: 'apikey' | 'bearer' | 'oauth2';
|
|
18
19
|
mcp?: boolean;
|
|
19
20
|
camelCase?: boolean;
|
|
21
|
+
/** Operator-supplied auth overrides (custom header, delegated login). */
|
|
22
|
+
authConfig?: AuthConfig;
|
|
20
23
|
}
|
|
21
24
|
export declare function generateAddonFromOpenAPI(spec: ParsedSpec, vars: AddonVars, flags: CodegenFlags): Record<string, string>;
|
|
22
25
|
export {};
|
package/dist/codegen.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, snakeToCamel, } from './zod-codegen.js';
|
|
2
2
|
import { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
3
|
+
import { authHeaderValue } from './auth-config.js';
|
|
3
4
|
function safeKey(key) {
|
|
4
5
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
5
6
|
}
|
|
@@ -283,8 +284,17 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
|
|
|
283
284
|
files[`src/functions/${named.functionName}.function.ts`] = funcCode;
|
|
284
285
|
functionExports.push(named.functionName);
|
|
285
286
|
}
|
|
287
|
+
// Delegated login: emit the upstream authenticate() implementation
|
|
288
|
+
const upstreamAuthExport = flags.authConfig?.delegated
|
|
289
|
+
? `authenticate${vars.pascalName}Upstream`
|
|
290
|
+
: undefined;
|
|
291
|
+
if (flags.authConfig?.delegated) {
|
|
292
|
+
files[`src/${name}-upstream-auth.ts`] = generateUpstreamAuthFile(vars, flags.authConfig);
|
|
293
|
+
}
|
|
286
294
|
// Generate index.ts with all exports
|
|
287
|
-
files['src/index.ts'] = generateIndexFile(functionExports
|
|
295
|
+
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
296
|
+
upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
|
|
297
|
+
});
|
|
288
298
|
// Generate typed API service class with route map
|
|
289
299
|
files[`src/${name}-api.service.ts`] = generateServiceFile(spec, opPairs, vars, flags);
|
|
290
300
|
// Generate variable file for BASE_URL
|
|
@@ -749,11 +759,169 @@ function buildOutputSchema(schema, ctx) {
|
|
|
749
759
|
}
|
|
750
760
|
return schemaToZod(schema, ctx);
|
|
751
761
|
}
|
|
752
|
-
function generateIndexFile(functionExports) {
|
|
762
|
+
function generateIndexFile(functionExports, extras) {
|
|
753
763
|
const lines = [];
|
|
754
764
|
for (const name of functionExports) {
|
|
755
765
|
lines.push(`export { ${name} } from './functions/${name}.function.js'`);
|
|
756
766
|
}
|
|
767
|
+
if (extras?.upstreamAuth) {
|
|
768
|
+
lines.push(`export { ${extras.upstreamAuth.export} } from './${extras.upstreamAuth.name}-upstream-auth.js'`);
|
|
769
|
+
}
|
|
770
|
+
lines.push('');
|
|
771
|
+
return lines.join('\n');
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
775
|
+
* delegated login. It performs the upstream login call described by the auth
|
|
776
|
+
* config, extracts the token, reads identity claims from the decoded JWT
|
|
777
|
+
* payload (base64url, NOT signature-verified — the token was just received
|
|
778
|
+
* over TLS from the login we ourselves performed) or the response body, and
|
|
779
|
+
* returns an identity object structurally compatible with
|
|
780
|
+
* `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
|
|
781
|
+
*/
|
|
782
|
+
function generateUpstreamAuthFile(vars, authConfig) {
|
|
783
|
+
const { pascalName } = vars;
|
|
784
|
+
const delegated = authConfig.delegated;
|
|
785
|
+
const claims = delegated.claims;
|
|
786
|
+
const namePaths = claims.name
|
|
787
|
+
? Array.isArray(claims.name)
|
|
788
|
+
? claims.name
|
|
789
|
+
: [claims.name]
|
|
790
|
+
: [];
|
|
791
|
+
const lines = [];
|
|
792
|
+
lines.push(`export interface ${pascalName}UpstreamIdentity {`);
|
|
793
|
+
lines.push(' externalId: string');
|
|
794
|
+
lines.push(' email: string');
|
|
795
|
+
lines.push(' name?: string');
|
|
796
|
+
lines.push(' role?: string');
|
|
797
|
+
lines.push(' tenantId?: string');
|
|
798
|
+
lines.push(' credential: { token: string; expiresAt?: number; tenantId?: string }');
|
|
799
|
+
lines.push('}');
|
|
800
|
+
lines.push('');
|
|
801
|
+
lines.push(`export interface ${pascalName}UpstreamCredentials {`);
|
|
802
|
+
lines.push(' email?: string');
|
|
803
|
+
lines.push(' password?: string');
|
|
804
|
+
lines.push(' apiKey?: string');
|
|
805
|
+
lines.push('}');
|
|
806
|
+
lines.push('');
|
|
807
|
+
lines.push('const pick = (obj: unknown, path: string): unknown =>');
|
|
808
|
+
lines.push(' path');
|
|
809
|
+
lines.push(" .split('.')");
|
|
810
|
+
lines.push(' .reduce<any>(');
|
|
811
|
+
lines.push(" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),");
|
|
812
|
+
lines.push(' obj');
|
|
813
|
+
lines.push(' )');
|
|
814
|
+
lines.push('');
|
|
815
|
+
lines.push('const str = (value: unknown): string | undefined =>');
|
|
816
|
+
lines.push(" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined");
|
|
817
|
+
lines.push('');
|
|
818
|
+
lines.push('/** Decode a JWT payload without verifying — see file docblock. */');
|
|
819
|
+
lines.push('const decodeJwtPayload = (token: string): unknown => {');
|
|
820
|
+
lines.push(" const part = token.split('.')[1]");
|
|
821
|
+
lines.push(' if (!part) return undefined');
|
|
822
|
+
lines.push(' try {');
|
|
823
|
+
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)");
|
|
824
|
+
lines.push(" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))");
|
|
825
|
+
lines.push(' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))');
|
|
826
|
+
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))');
|
|
827
|
+
lines.push(' } catch {');
|
|
828
|
+
lines.push(' // Not a decodable JWT — caller treats it as missing claims.');
|
|
829
|
+
lines.push(' return undefined');
|
|
830
|
+
lines.push(' }');
|
|
831
|
+
lines.push('}');
|
|
832
|
+
lines.push('');
|
|
833
|
+
lines.push('/**');
|
|
834
|
+
lines.push(` * Verify credentials against the upstream ${vars.displayName} login and map`);
|
|
835
|
+
lines.push(' * its response onto an upstream identity. Returns null when the upstream');
|
|
836
|
+
lines.push(' * rejects the credentials; network errors propagate to the caller.');
|
|
837
|
+
lines.push(' */');
|
|
838
|
+
lines.push(`export const authenticate${pascalName}Upstream = async (`);
|
|
839
|
+
lines.push(` credentials: ${pascalName}UpstreamCredentials,`);
|
|
840
|
+
lines.push(' baseUrl: string');
|
|
841
|
+
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`);
|
|
842
|
+
lines.push(' const headers: Record<string, string> = {');
|
|
843
|
+
lines.push(" 'Content-Type': 'application/json',");
|
|
844
|
+
for (const [header, value] of Object.entries(authConfig.extraHeaders ?? {})) {
|
|
845
|
+
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
846
|
+
}
|
|
847
|
+
lines.push(' }');
|
|
848
|
+
lines.push(' const body: Record<string, string> = {}');
|
|
849
|
+
if (delegated.credentials.includes('email')) {
|
|
850
|
+
lines.push(' if (credentials.email) body.email = credentials.email');
|
|
851
|
+
}
|
|
852
|
+
if (delegated.credentials.includes('password')) {
|
|
853
|
+
lines.push(' if (credentials.password) body.password = credentials.password');
|
|
854
|
+
}
|
|
855
|
+
if (delegated.credentials.includes('apiKey')) {
|
|
856
|
+
lines.push(` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`);
|
|
857
|
+
}
|
|
858
|
+
lines.push('');
|
|
859
|
+
lines.push(` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`);
|
|
860
|
+
lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`);
|
|
861
|
+
lines.push(' headers,');
|
|
862
|
+
lines.push(' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,');
|
|
863
|
+
lines.push(' })');
|
|
864
|
+
lines.push(' if (!response.ok) return null');
|
|
865
|
+
lines.push('');
|
|
866
|
+
lines.push(' const data: unknown = await response.json()');
|
|
867
|
+
lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`);
|
|
868
|
+
lines.push(' if (!token) return null');
|
|
869
|
+
lines.push('');
|
|
870
|
+
if (claims.source === 'jwt') {
|
|
871
|
+
lines.push(' const claims = decodeJwtPayload(token)');
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
lines.push(' const claims = data');
|
|
875
|
+
}
|
|
876
|
+
lines.push(' if (!claims) return null');
|
|
877
|
+
lines.push('');
|
|
878
|
+
lines.push(` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`);
|
|
879
|
+
lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`);
|
|
880
|
+
lines.push(' if (!externalId || !email) return null');
|
|
881
|
+
lines.push('');
|
|
882
|
+
if (namePaths.length > 0) {
|
|
883
|
+
const parts = namePaths
|
|
884
|
+
.map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
|
|
885
|
+
.join(', ');
|
|
886
|
+
lines.push(` const name = [${parts}]`);
|
|
887
|
+
lines.push(' .filter((part): part is string => Boolean(part))');
|
|
888
|
+
lines.push(" .join(' ') || undefined");
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
lines.push(' const name = undefined');
|
|
892
|
+
}
|
|
893
|
+
if (claims.role) {
|
|
894
|
+
lines.push(` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
lines.push(' const role = undefined');
|
|
898
|
+
}
|
|
899
|
+
if (claims.tenantId) {
|
|
900
|
+
lines.push(` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`);
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
lines.push(' const tenantId = undefined');
|
|
904
|
+
}
|
|
905
|
+
if (delegated.expiresAtPath) {
|
|
906
|
+
lines.push(` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`);
|
|
907
|
+
}
|
|
908
|
+
else if (claims.source === 'jwt') {
|
|
909
|
+
lines.push(" const expiresAtRaw = pick(claims, 'exp')");
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
lines.push(' const expiresAtRaw = undefined');
|
|
913
|
+
}
|
|
914
|
+
lines.push(" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined");
|
|
915
|
+
lines.push('');
|
|
916
|
+
lines.push(' return {');
|
|
917
|
+
lines.push(' externalId,');
|
|
918
|
+
lines.push(' email,');
|
|
919
|
+
lines.push(' name,');
|
|
920
|
+
lines.push(' role,');
|
|
921
|
+
lines.push(' tenantId,');
|
|
922
|
+
lines.push(' credential: { token, expiresAt, tenantId },');
|
|
923
|
+
lines.push(' }');
|
|
924
|
+
lines.push('}');
|
|
757
925
|
lines.push('');
|
|
758
926
|
return lines.join('\n');
|
|
759
927
|
}
|
|
@@ -878,6 +1046,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
878
1046
|
lines.push(' const query: Record<string, string> = {}');
|
|
879
1047
|
lines.push(' const headers: Record<string, string> = {');
|
|
880
1048
|
lines.push(" 'Content-Type': 'application/json',");
|
|
1049
|
+
for (const [header, value] of Object.entries(flags.authConfig?.extraHeaders ?? {})) {
|
|
1050
|
+
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`);
|
|
1051
|
+
}
|
|
881
1052
|
lines.push(' }');
|
|
882
1053
|
lines.push('');
|
|
883
1054
|
// When camelCase flag is on, use rawData (snake_case converted) for route matching
|
|
@@ -919,12 +1090,23 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
919
1090
|
if (flags.credential && flags.credential !== 'oauth2') {
|
|
920
1091
|
// Per-user credential: use creds from wire.getCredentials()
|
|
921
1092
|
if (flags.credential === 'bearer') {
|
|
922
|
-
|
|
1093
|
+
if (flags.authConfig?.headerName) {
|
|
1094
|
+
// Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
|
|
1095
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.token');
|
|
1096
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`');
|
|
1100
|
+
}
|
|
923
1101
|
}
|
|
924
1102
|
else {
|
|
925
|
-
// apikey: check spec for custom header name
|
|
1103
|
+
// apikey: auth-config override wins, else check spec for custom header name
|
|
926
1104
|
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
927
|
-
if (
|
|
1105
|
+
if (flags.authConfig?.headerName) {
|
|
1106
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1107
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1108
|
+
}
|
|
1109
|
+
else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
928
1110
|
lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
|
|
929
1111
|
}
|
|
930
1112
|
else {
|
|
@@ -946,9 +1128,13 @@ function generateServiceFile(spec, opPairs, vars, flags) {
|
|
|
946
1128
|
lines.push(' })');
|
|
947
1129
|
}
|
|
948
1130
|
else if (flags.secret) {
|
|
949
|
-
//
|
|
1131
|
+
// Auth-config override wins, else use apiKey details from spec if available
|
|
950
1132
|
const apiKeyScheme = Object.values(spec.securitySchemes).find((s) => s.type === 'apiKey');
|
|
951
|
-
if (
|
|
1133
|
+
if (flags.authConfig?.headerName) {
|
|
1134
|
+
const { header, value } = authHeaderValue(flags.authConfig, 'this.creds.apiKey');
|
|
1135
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`);
|
|
1136
|
+
}
|
|
1137
|
+
else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
952
1138
|
lines.push(` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`);
|
|
953
1139
|
}
|
|
954
1140
|
else {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, type ParsedSpec, type ParsedOperation, type ParsedParam, type ErrorResponse, type SecuritySchemeInfo, } from './parse-openapi.js';
|
|
2
2
|
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
|
+
export { AuthConfigSchema, DelegatedLoginSchema, loadAuthConfig, authHeaderValue, type AuthConfig, type DelegatedLoginConfig, } from './auth-config.js';
|
|
3
4
|
export { generateOperationNames, detectCommonPrefix, type NamedOperation, } from './naming.js';
|
|
4
5
|
export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseOpenAPISpec, computeContractHash, } from './parse-openapi.js';
|
|
2
2
|
export { generateAddonFromOpenAPI } from './codegen.js';
|
|
3
|
+
export { AuthConfigSchema, DelegatedLoginSchema, loadAuthConfig, authHeaderValue, } from './auth-config.js';
|
|
3
4
|
export { generateOperationNames, detectCommonPrefix, } from './naming.js';
|
|
4
5
|
export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
|
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator/agent-supplied auth configuration that overrides or augments an
|
|
3
|
+
* OpenAPI spec's securitySchemes. Needed for real-world enterprise specs that
|
|
4
|
+
* carry no machine-readable auth at all (auth described only in prose), or
|
|
5
|
+
* that use a custom auth header the spec doesn't declare.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile } from 'fs/promises'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
|
|
10
|
+
export const DelegatedClaimsSchema = z.object({
|
|
11
|
+
/** Where identity claims live: the decoded login-JWT payload, or the login response body. */
|
|
12
|
+
source: z.enum(['jwt', 'response']),
|
|
13
|
+
/** Dot-path to the stable upstream user id (e.g. 'user._id'). */
|
|
14
|
+
externalId: z.string(),
|
|
15
|
+
email: z.string(),
|
|
16
|
+
/** One dot-path, or several joined with a space (e.g. first + last name). */
|
|
17
|
+
name: z.union([z.string(), z.array(z.string())]).optional(),
|
|
18
|
+
role: z.string().optional(),
|
|
19
|
+
tenantId: z.string().optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const DelegatedLoginSchema = z.object({
|
|
23
|
+
/** Spec-relative path of the login operation (e.g. '/users/login'). */
|
|
24
|
+
loginPath: z.string(),
|
|
25
|
+
loginMethod: z.string().default('post'),
|
|
26
|
+
/** Which credential fields the login accepts. email/password go in the JSON body; apiKey in a header. */
|
|
27
|
+
credentials: z
|
|
28
|
+
.array(z.enum(['email', 'password', 'apiKey']))
|
|
29
|
+
.default(['email', 'password']),
|
|
30
|
+
/** Header carrying the apiKey when 'apiKey' is a supported credential. */
|
|
31
|
+
apiKeyHeader: z.string().default('x-api-key'),
|
|
32
|
+
/** Dot-path to the token in the login response (e.g. 'token'). */
|
|
33
|
+
tokenPath: z.string(),
|
|
34
|
+
/** Dot-path to an epoch-seconds expiry in the response; defaults to the JWT `exp` claim when source is jwt. */
|
|
35
|
+
expiresAtPath: z.string().optional(),
|
|
36
|
+
claims: DelegatedClaimsSchema,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const AuthConfigSchema = z.object({
|
|
40
|
+
/** Auth header the API expects on authenticated calls (e.g. 'authentication'). Overrides the spec. */
|
|
41
|
+
headerName: z.string().optional(),
|
|
42
|
+
/** 'raw' sends the bare token; 'bearer' prefixes it. Default: bearer for Authorization, raw for custom headers. */
|
|
43
|
+
headerFormat: z.enum(['raw', 'bearer']).optional(),
|
|
44
|
+
/**
|
|
45
|
+
* Static headers sent on EVERY request — the delegated login call and all
|
|
46
|
+
* proxied API calls. Needed for upstreams that route on a header, e.g. a
|
|
47
|
+
* multi-tenant API that resolves the tenant from an `Origin` header and
|
|
48
|
+
* rejects requests without it.
|
|
49
|
+
*/
|
|
50
|
+
extraHeaders: z.record(z.string(), z.string()).optional(),
|
|
51
|
+
/** Present when end-users sign in with their existing upstream credentials (delegated login). */
|
|
52
|
+
delegated: DelegatedLoginSchema.optional(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export type DelegatedLoginConfig = z.infer<typeof DelegatedLoginSchema>
|
|
56
|
+
export type AuthConfig = z.infer<typeof AuthConfigSchema>
|
|
57
|
+
|
|
58
|
+
/** The effective value template for the auth header. */
|
|
59
|
+
export const authHeaderValue = (
|
|
60
|
+
config: AuthConfig,
|
|
61
|
+
tokenExpr: string
|
|
62
|
+
): { header: string; value: string } => {
|
|
63
|
+
const header = config.headerName ?? 'Authorization'
|
|
64
|
+
const format =
|
|
65
|
+
config.headerFormat ??
|
|
66
|
+
(config.headerName && config.headerName !== 'Authorization'
|
|
67
|
+
? 'raw'
|
|
68
|
+
: 'bearer')
|
|
69
|
+
return {
|
|
70
|
+
header,
|
|
71
|
+
value: format === 'bearer' ? `\`Bearer \${${tokenExpr}}\`` : tokenExpr,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load and validate an auth-config JSON file; throws with the zod issue list on mismatch. */
|
|
76
|
+
export async function loadAuthConfig(filePath: string): Promise<AuthConfig> {
|
|
77
|
+
const raw = await readFile(filePath, 'utf-8')
|
|
78
|
+
let parsed: unknown
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Auth config ${filePath} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
const result = AuthConfigSchema.safeParse(parsed)
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
const issues = result.error.issues
|
|
89
|
+
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
90
|
+
.join('\n')
|
|
91
|
+
throw new Error(`Auth config ${filePath} is invalid:\n${issues}`)
|
|
92
|
+
}
|
|
93
|
+
return result.data
|
|
94
|
+
}
|
package/src/codegen.test.ts
CHANGED
|
@@ -845,3 +845,123 @@ describe('function description synthesis', () => {
|
|
|
845
845
|
)
|
|
846
846
|
})
|
|
847
847
|
})
|
|
848
|
+
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
// Auth-config: custom headers + delegated login
|
|
851
|
+
// ---------------------------------------------------------------------------
|
|
852
|
+
describe('auth config', () => {
|
|
853
|
+
const delegatedConfig = {
|
|
854
|
+
headerName: 'authentication',
|
|
855
|
+
headerFormat: 'raw' as const,
|
|
856
|
+
delegated: {
|
|
857
|
+
loginPath: '/users/login-ai-plugin',
|
|
858
|
+
loginMethod: 'post',
|
|
859
|
+
credentials: ['email', 'password'] as ('email' | 'password' | 'apiKey')[],
|
|
860
|
+
apiKeyHeader: 'x-api-key',
|
|
861
|
+
tokenPath: 'token',
|
|
862
|
+
claims: {
|
|
863
|
+
source: 'jwt' as const,
|
|
864
|
+
externalId: 'user._id',
|
|
865
|
+
email: 'user.email',
|
|
866
|
+
name: ['user.first_name', 'user.last_name'],
|
|
867
|
+
role: 'user.role',
|
|
868
|
+
tenantId: 'user.company',
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
test('custom raw header replaces Authorization Bearer in the bearer-credential service', () => {
|
|
874
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
875
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
876
|
+
oauth: false,
|
|
877
|
+
secret: false,
|
|
878
|
+
credential: 'bearer',
|
|
879
|
+
authConfig: delegatedConfig,
|
|
880
|
+
})
|
|
881
|
+
const service = files['src/test-api-api.service.ts']
|
|
882
|
+
assert.ok(
|
|
883
|
+
service.includes('headers["authentication"] = this.creds.token'),
|
|
884
|
+
'custom raw header should carry the bare token'
|
|
885
|
+
)
|
|
886
|
+
assert.ok(
|
|
887
|
+
!service.includes('headers.Authorization'),
|
|
888
|
+
'default Authorization header must not be emitted'
|
|
889
|
+
)
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
test('custom header with bearer format keeps the Bearer prefix', () => {
|
|
893
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
894
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
895
|
+
oauth: false,
|
|
896
|
+
secret: false,
|
|
897
|
+
credential: 'bearer',
|
|
898
|
+
authConfig: { headerName: 'x-auth', headerFormat: 'bearer' as const },
|
|
899
|
+
})
|
|
900
|
+
const service = files['src/test-api-api.service.ts']
|
|
901
|
+
assert.ok(
|
|
902
|
+
service.includes('headers["x-auth"] = `Bearer ${this.creds.token}`'),
|
|
903
|
+
'bearer format should keep the prefix on the custom header'
|
|
904
|
+
)
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
test('delegated config emits the upstream-auth file and exports it', () => {
|
|
908
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
909
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
910
|
+
oauth: false,
|
|
911
|
+
secret: false,
|
|
912
|
+
credential: 'bearer',
|
|
913
|
+
authConfig: delegatedConfig,
|
|
914
|
+
})
|
|
915
|
+
const authFile = files['src/test-api-upstream-auth.ts']
|
|
916
|
+
assert.ok(authFile, 'upstream auth file should be generated')
|
|
917
|
+
assert.ok(authFile.includes('export const authenticateTestApiUpstream'))
|
|
918
|
+
assert.ok(
|
|
919
|
+
authFile.includes('/users/login-ai-plugin'),
|
|
920
|
+
'login path baked in'
|
|
921
|
+
)
|
|
922
|
+
assert.ok(authFile.includes('"user._id"'), 'externalId claim path baked in')
|
|
923
|
+
assert.ok(
|
|
924
|
+
authFile.includes("pick(claims, 'exp')"),
|
|
925
|
+
'jwt source defaults expiry to the exp claim'
|
|
926
|
+
)
|
|
927
|
+
assert.ok(
|
|
928
|
+
files['src/index.ts'].includes(
|
|
929
|
+
"export { authenticateTestApiUpstream } from './test-api-upstream-auth.js'"
|
|
930
|
+
),
|
|
931
|
+
'index should export the authenticate function'
|
|
932
|
+
)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
test('extraHeaders are baked into the service and the upstream-auth login', () => {
|
|
936
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
937
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
938
|
+
oauth: false,
|
|
939
|
+
secret: false,
|
|
940
|
+
credential: 'bearer',
|
|
941
|
+
authConfig: {
|
|
942
|
+
...delegatedConfig,
|
|
943
|
+
extraHeaders: { Origin: 'https://tenant.example.com' },
|
|
944
|
+
},
|
|
945
|
+
})
|
|
946
|
+
const service = files['src/test-api-api.service.ts']
|
|
947
|
+
assert.ok(
|
|
948
|
+
service.includes('"Origin": "https://tenant.example.com",'),
|
|
949
|
+
'service headers init should carry the static header'
|
|
950
|
+
)
|
|
951
|
+
const authFile = files['src/test-api-upstream-auth.ts']
|
|
952
|
+
assert.ok(
|
|
953
|
+
authFile.includes('"Origin": "https://tenant.example.com",'),
|
|
954
|
+
'login fetch should carry the static header'
|
|
955
|
+
)
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
test('no delegated config → no upstream-auth file', () => {
|
|
959
|
+
const spec = makeSpec({ operations: [makeOp()] })
|
|
960
|
+
const files = generateAddonFromOpenAPI(spec, makeVars(), {
|
|
961
|
+
oauth: false,
|
|
962
|
+
secret: false,
|
|
963
|
+
credential: 'bearer',
|
|
964
|
+
})
|
|
965
|
+
assert.equal(files['src/test-api-upstream-auth.ts'], undefined)
|
|
966
|
+
})
|
|
967
|
+
})
|
package/src/codegen.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
detectCommonPrefix,
|
|
21
21
|
type NamedOperation,
|
|
22
22
|
} from './naming.js'
|
|
23
|
+
import { authHeaderValue, type AuthConfig } from './auth-config.js'
|
|
23
24
|
|
|
24
25
|
interface AddonVars {
|
|
25
26
|
name: string
|
|
@@ -36,6 +37,8 @@ interface CodegenFlags {
|
|
|
36
37
|
credential?: 'apikey' | 'bearer' | 'oauth2'
|
|
37
38
|
mcp?: boolean
|
|
38
39
|
camelCase?: boolean
|
|
40
|
+
/** Operator-supplied auth overrides (custom header, delegated login). */
|
|
41
|
+
authConfig?: AuthConfig
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
function safeKey(key: string): string {
|
|
@@ -417,8 +420,21 @@ export function generateAddonFromOpenAPI(
|
|
|
417
420
|
functionExports.push(named.functionName)
|
|
418
421
|
}
|
|
419
422
|
|
|
423
|
+
// Delegated login: emit the upstream authenticate() implementation
|
|
424
|
+
const upstreamAuthExport = flags.authConfig?.delegated
|
|
425
|
+
? `authenticate${vars.pascalName}Upstream`
|
|
426
|
+
: undefined
|
|
427
|
+
if (flags.authConfig?.delegated) {
|
|
428
|
+
files[`src/${name}-upstream-auth.ts`] = generateUpstreamAuthFile(
|
|
429
|
+
vars,
|
|
430
|
+
flags.authConfig
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
420
434
|
// Generate index.ts with all exports
|
|
421
|
-
files['src/index.ts'] = generateIndexFile(functionExports
|
|
435
|
+
files['src/index.ts'] = generateIndexFile(functionExports, {
|
|
436
|
+
upstreamAuth: upstreamAuthExport ? { name, export: upstreamAuthExport } : undefined,
|
|
437
|
+
})
|
|
422
438
|
|
|
423
439
|
// Generate typed API service class with route map
|
|
424
440
|
files[`src/${name}-api.service.ts`] = generateServiceFile(
|
|
@@ -1008,11 +1024,209 @@ function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
|
|
|
1008
1024
|
return schemaToZod(schema, ctx)
|
|
1009
1025
|
}
|
|
1010
1026
|
|
|
1011
|
-
function generateIndexFile(
|
|
1027
|
+
function generateIndexFile(
|
|
1028
|
+
functionExports: string[],
|
|
1029
|
+
extras?: { upstreamAuth?: { name: string; export: string } }
|
|
1030
|
+
): string {
|
|
1012
1031
|
const lines: string[] = []
|
|
1013
1032
|
for (const name of functionExports) {
|
|
1014
1033
|
lines.push(`export { ${name} } from './functions/${name}.function.js'`)
|
|
1015
1034
|
}
|
|
1035
|
+
if (extras?.upstreamAuth) {
|
|
1036
|
+
lines.push(
|
|
1037
|
+
`export { ${extras.upstreamAuth.export} } from './${extras.upstreamAuth.name}-upstream-auth.js'`
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
lines.push('')
|
|
1041
|
+
return lines.join('\n')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Emit `src/<name>-upstream-auth.ts`: a self-contained authenticate() for
|
|
1046
|
+
* delegated login. It performs the upstream login call described by the auth
|
|
1047
|
+
* config, extracts the token, reads identity claims from the decoded JWT
|
|
1048
|
+
* payload (base64url, NOT signature-verified — the token was just received
|
|
1049
|
+
* over TLS from the login we ourselves performed) or the response body, and
|
|
1050
|
+
* returns an identity object structurally compatible with
|
|
1051
|
+
* `@pikku/better-auth`'s `UpstreamIdentity` — without depending on it.
|
|
1052
|
+
*/
|
|
1053
|
+
function generateUpstreamAuthFile(
|
|
1054
|
+
vars: AddonVars,
|
|
1055
|
+
authConfig: AuthConfig
|
|
1056
|
+
): string {
|
|
1057
|
+
const { pascalName } = vars
|
|
1058
|
+
const delegated = authConfig.delegated!
|
|
1059
|
+
const claims = delegated.claims
|
|
1060
|
+
const namePaths = claims.name
|
|
1061
|
+
? Array.isArray(claims.name)
|
|
1062
|
+
? claims.name
|
|
1063
|
+
: [claims.name]
|
|
1064
|
+
: []
|
|
1065
|
+
|
|
1066
|
+
const lines: string[] = []
|
|
1067
|
+
lines.push(`export interface ${pascalName}UpstreamIdentity {`)
|
|
1068
|
+
lines.push(' externalId: string')
|
|
1069
|
+
lines.push(' email: string')
|
|
1070
|
+
lines.push(' name?: string')
|
|
1071
|
+
lines.push(' role?: string')
|
|
1072
|
+
lines.push(' tenantId?: string')
|
|
1073
|
+
lines.push(
|
|
1074
|
+
' credential: { token: string; expiresAt?: number; tenantId?: string }'
|
|
1075
|
+
)
|
|
1076
|
+
lines.push('}')
|
|
1077
|
+
lines.push('')
|
|
1078
|
+
lines.push(
|
|
1079
|
+
`export interface ${pascalName}UpstreamCredentials {`
|
|
1080
|
+
)
|
|
1081
|
+
lines.push(' email?: string')
|
|
1082
|
+
lines.push(' password?: string')
|
|
1083
|
+
lines.push(' apiKey?: string')
|
|
1084
|
+
lines.push('}')
|
|
1085
|
+
lines.push('')
|
|
1086
|
+
lines.push('const pick = (obj: unknown, path: string): unknown =>')
|
|
1087
|
+
lines.push(' path')
|
|
1088
|
+
lines.push(" .split('.')")
|
|
1089
|
+
lines.push(' .reduce<any>(')
|
|
1090
|
+
lines.push(
|
|
1091
|
+
" (o, key) => (o && typeof o === 'object' ? o[key] : undefined),"
|
|
1092
|
+
)
|
|
1093
|
+
lines.push(' obj')
|
|
1094
|
+
lines.push(' )')
|
|
1095
|
+
lines.push('')
|
|
1096
|
+
lines.push('const str = (value: unknown): string | undefined =>')
|
|
1097
|
+
lines.push(
|
|
1098
|
+
" typeof value === 'string' && value ? value : typeof value === 'number' ? String(value) : undefined"
|
|
1099
|
+
)
|
|
1100
|
+
lines.push('')
|
|
1101
|
+
lines.push('/** Decode a JWT payload without verifying — see file docblock. */')
|
|
1102
|
+
lines.push('const decodeJwtPayload = (token: string): unknown => {')
|
|
1103
|
+
lines.push(" const part = token.split('.')[1]")
|
|
1104
|
+
lines.push(' if (!part) return undefined')
|
|
1105
|
+
lines.push(' try {')
|
|
1106
|
+
lines.push(" const pad = part + '==='.slice((part.length + 3) % 4)")
|
|
1107
|
+
lines.push(
|
|
1108
|
+
" const bin = atob(pad.replace(/-/g, '+').replace(/_/g, '/'))"
|
|
1109
|
+
)
|
|
1110
|
+
lines.push(
|
|
1111
|
+
' const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))'
|
|
1112
|
+
)
|
|
1113
|
+
lines.push(' return JSON.parse(new TextDecoder().decode(bytes))')
|
|
1114
|
+
lines.push(' } catch {')
|
|
1115
|
+
lines.push(' // Not a decodable JWT — caller treats it as missing claims.')
|
|
1116
|
+
lines.push(' return undefined')
|
|
1117
|
+
lines.push(' }')
|
|
1118
|
+
lines.push('}')
|
|
1119
|
+
lines.push('')
|
|
1120
|
+
lines.push('/**')
|
|
1121
|
+
lines.push(
|
|
1122
|
+
` * Verify credentials against the upstream ${vars.displayName} login and map`
|
|
1123
|
+
)
|
|
1124
|
+
lines.push(
|
|
1125
|
+
' * its response onto an upstream identity. Returns null when the upstream'
|
|
1126
|
+
)
|
|
1127
|
+
lines.push(
|
|
1128
|
+
' * rejects the credentials; network errors propagate to the caller.'
|
|
1129
|
+
)
|
|
1130
|
+
lines.push(' */')
|
|
1131
|
+
lines.push(`export const authenticate${pascalName}Upstream = async (`)
|
|
1132
|
+
lines.push(` credentials: ${pascalName}UpstreamCredentials,`)
|
|
1133
|
+
lines.push(' baseUrl: string')
|
|
1134
|
+
lines.push(`): Promise<${pascalName}UpstreamIdentity | null> => {`)
|
|
1135
|
+
lines.push(' const headers: Record<string, string> = {')
|
|
1136
|
+
lines.push(" 'Content-Type': 'application/json',")
|
|
1137
|
+
for (const [header, value] of Object.entries(authConfig.extraHeaders ?? {})) {
|
|
1138
|
+
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`)
|
|
1139
|
+
}
|
|
1140
|
+
lines.push(' }')
|
|
1141
|
+
lines.push(' const body: Record<string, string> = {}')
|
|
1142
|
+
if (delegated.credentials.includes('email')) {
|
|
1143
|
+
lines.push(' if (credentials.email) body.email = credentials.email')
|
|
1144
|
+
}
|
|
1145
|
+
if (delegated.credentials.includes('password')) {
|
|
1146
|
+
lines.push(
|
|
1147
|
+
' if (credentials.password) body.password = credentials.password'
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
if (delegated.credentials.includes('apiKey')) {
|
|
1151
|
+
lines.push(
|
|
1152
|
+
` if (credentials.apiKey) headers[${JSON.stringify(delegated.apiKeyHeader)}] = credentials.apiKey`
|
|
1153
|
+
)
|
|
1154
|
+
}
|
|
1155
|
+
lines.push('')
|
|
1156
|
+
lines.push(
|
|
1157
|
+
` const response = await fetch(\`\${baseUrl.replace(/\\/+$/, '')}${delegated.loginPath}\`, {`
|
|
1158
|
+
)
|
|
1159
|
+
lines.push(` method: ${JSON.stringify(delegated.loginMethod.toUpperCase())},`)
|
|
1160
|
+
lines.push(' headers,')
|
|
1161
|
+
lines.push(
|
|
1162
|
+
' body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,'
|
|
1163
|
+
)
|
|
1164
|
+
lines.push(' })')
|
|
1165
|
+
lines.push(' if (!response.ok) return null')
|
|
1166
|
+
lines.push('')
|
|
1167
|
+
lines.push(' const data: unknown = await response.json()')
|
|
1168
|
+
lines.push(` const token = str(pick(data, ${JSON.stringify(delegated.tokenPath)}))`)
|
|
1169
|
+
lines.push(' if (!token) return null')
|
|
1170
|
+
lines.push('')
|
|
1171
|
+
if (claims.source === 'jwt') {
|
|
1172
|
+
lines.push(' const claims = decodeJwtPayload(token)')
|
|
1173
|
+
} else {
|
|
1174
|
+
lines.push(' const claims = data')
|
|
1175
|
+
}
|
|
1176
|
+
lines.push(' if (!claims) return null')
|
|
1177
|
+
lines.push('')
|
|
1178
|
+
lines.push(
|
|
1179
|
+
` const externalId = str(pick(claims, ${JSON.stringify(claims.externalId)}))`
|
|
1180
|
+
)
|
|
1181
|
+
lines.push(` const email = str(pick(claims, ${JSON.stringify(claims.email)}))`)
|
|
1182
|
+
lines.push(' if (!externalId || !email) return null')
|
|
1183
|
+
lines.push('')
|
|
1184
|
+
if (namePaths.length > 0) {
|
|
1185
|
+
const parts = namePaths
|
|
1186
|
+
.map((p) => `str(pick(claims, ${JSON.stringify(p)}))`)
|
|
1187
|
+
.join(', ')
|
|
1188
|
+
lines.push(` const name = [${parts}]`)
|
|
1189
|
+
lines.push(' .filter((part): part is string => Boolean(part))')
|
|
1190
|
+
lines.push(" .join(' ') || undefined")
|
|
1191
|
+
} else {
|
|
1192
|
+
lines.push(' const name = undefined')
|
|
1193
|
+
}
|
|
1194
|
+
if (claims.role) {
|
|
1195
|
+
lines.push(
|
|
1196
|
+
` const role = str(pick(claims, ${JSON.stringify(claims.role)}))`
|
|
1197
|
+
)
|
|
1198
|
+
} else {
|
|
1199
|
+
lines.push(' const role = undefined')
|
|
1200
|
+
}
|
|
1201
|
+
if (claims.tenantId) {
|
|
1202
|
+
lines.push(
|
|
1203
|
+
` const tenantId = str(pick(claims, ${JSON.stringify(claims.tenantId)}))`
|
|
1204
|
+
)
|
|
1205
|
+
} else {
|
|
1206
|
+
lines.push(' const tenantId = undefined')
|
|
1207
|
+
}
|
|
1208
|
+
if (delegated.expiresAtPath) {
|
|
1209
|
+
lines.push(
|
|
1210
|
+
` const expiresAtRaw = pick(data, ${JSON.stringify(delegated.expiresAtPath)})`
|
|
1211
|
+
)
|
|
1212
|
+
} else if (claims.source === 'jwt') {
|
|
1213
|
+
lines.push(" const expiresAtRaw = pick(claims, 'exp')")
|
|
1214
|
+
} else {
|
|
1215
|
+
lines.push(' const expiresAtRaw = undefined')
|
|
1216
|
+
}
|
|
1217
|
+
lines.push(
|
|
1218
|
+
" const expiresAt = typeof expiresAtRaw === 'number' ? expiresAtRaw : undefined"
|
|
1219
|
+
)
|
|
1220
|
+
lines.push('')
|
|
1221
|
+
lines.push(' return {')
|
|
1222
|
+
lines.push(' externalId,')
|
|
1223
|
+
lines.push(' email,')
|
|
1224
|
+
lines.push(' name,')
|
|
1225
|
+
lines.push(' role,')
|
|
1226
|
+
lines.push(' tenantId,')
|
|
1227
|
+
lines.push(' credential: { token, expiresAt, tenantId },')
|
|
1228
|
+
lines.push(' }')
|
|
1229
|
+
lines.push('}')
|
|
1016
1230
|
lines.push('')
|
|
1017
1231
|
return lines.join('\n')
|
|
1018
1232
|
}
|
|
@@ -1194,6 +1408,11 @@ function generateServiceFile(
|
|
|
1194
1408
|
lines.push(' const query: Record<string, string> = {}')
|
|
1195
1409
|
lines.push(' const headers: Record<string, string> = {')
|
|
1196
1410
|
lines.push(" 'Content-Type': 'application/json',")
|
|
1411
|
+
for (const [header, value] of Object.entries(
|
|
1412
|
+
flags.authConfig?.extraHeaders ?? {}
|
|
1413
|
+
)) {
|
|
1414
|
+
lines.push(` ${JSON.stringify(header)}: ${JSON.stringify(value)},`)
|
|
1415
|
+
}
|
|
1197
1416
|
lines.push(' }')
|
|
1198
1417
|
lines.push('')
|
|
1199
1418
|
// When camelCase flag is on, use rawData (snake_case converted) for route matching
|
|
@@ -1242,13 +1461,28 @@ function generateServiceFile(
|
|
|
1242
1461
|
if (flags.credential && flags.credential !== 'oauth2') {
|
|
1243
1462
|
// Per-user credential: use creds from wire.getCredentials()
|
|
1244
1463
|
if (flags.credential === 'bearer') {
|
|
1245
|
-
|
|
1464
|
+
if (flags.authConfig?.headerName) {
|
|
1465
|
+
// Custom auth header (e.g. `authentication: <raw jwt>`) from the auth config.
|
|
1466
|
+
const { header, value } = authHeaderValue(
|
|
1467
|
+
flags.authConfig,
|
|
1468
|
+
'this.creds.token'
|
|
1469
|
+
)
|
|
1470
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1471
|
+
} else {
|
|
1472
|
+
lines.push(' headers.Authorization = `Bearer ${this.creds.token}`')
|
|
1473
|
+
}
|
|
1246
1474
|
} else {
|
|
1247
|
-
// apikey: check spec for custom header name
|
|
1475
|
+
// apikey: auth-config override wins, else check spec for custom header name
|
|
1248
1476
|
const apiKeyScheme = Object.values(spec.securitySchemes).find(
|
|
1249
1477
|
(s) => s.type === 'apiKey'
|
|
1250
1478
|
)
|
|
1251
|
-
if (
|
|
1479
|
+
if (flags.authConfig?.headerName) {
|
|
1480
|
+
const { header, value } = authHeaderValue(
|
|
1481
|
+
flags.authConfig,
|
|
1482
|
+
'this.creds.apiKey'
|
|
1483
|
+
)
|
|
1484
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1485
|
+
} else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
1252
1486
|
lines.push(
|
|
1253
1487
|
` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
|
|
1254
1488
|
)
|
|
@@ -1271,11 +1505,17 @@ function generateServiceFile(
|
|
|
1271
1505
|
lines.push(' body: body ? JSON.stringify(body) : undefined,')
|
|
1272
1506
|
lines.push(' })')
|
|
1273
1507
|
} else if (flags.secret) {
|
|
1274
|
-
//
|
|
1508
|
+
// Auth-config override wins, else use apiKey details from spec if available
|
|
1275
1509
|
const apiKeyScheme = Object.values(spec.securitySchemes).find(
|
|
1276
1510
|
(s) => s.type === 'apiKey'
|
|
1277
1511
|
)
|
|
1278
|
-
if (
|
|
1512
|
+
if (flags.authConfig?.headerName) {
|
|
1513
|
+
const { header, value } = authHeaderValue(
|
|
1514
|
+
flags.authConfig,
|
|
1515
|
+
'this.creds.apiKey'
|
|
1516
|
+
)
|
|
1517
|
+
lines.push(` headers[${JSON.stringify(header)}] = ${value}`)
|
|
1518
|
+
} else if (apiKeyScheme?.name && apiKeyScheme?.in === 'header') {
|
|
1279
1519
|
lines.push(
|
|
1280
1520
|
` headers[${JSON.stringify(apiKeyScheme.name)}] = this.creds.apiKey`
|
|
1281
1521
|
)
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,15 @@ export {
|
|
|
10
10
|
|
|
11
11
|
export { generateAddonFromOpenAPI } from './codegen.js'
|
|
12
12
|
|
|
13
|
+
export {
|
|
14
|
+
AuthConfigSchema,
|
|
15
|
+
DelegatedLoginSchema,
|
|
16
|
+
loadAuthConfig,
|
|
17
|
+
authHeaderValue,
|
|
18
|
+
type AuthConfig,
|
|
19
|
+
type DelegatedLoginConfig,
|
|
20
|
+
} from './auth-config.js'
|
|
21
|
+
|
|
13
22
|
export {
|
|
14
23
|
generateOperationNames,
|
|
15
24
|
detectCommonPrefix,
|
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/codegen.ts","./src/index.ts","./src/naming.ts","./src/openapi-to-zod-schema.ts","./src/parse-openapi.ts","./src/zod-codegen.ts"],"version":"6.0.3"}
|
|
1
|
+
{"root":["./src/auth-config.ts","./src/codegen.ts","./src/index.ts","./src/naming.ts","./src/openapi-to-zod-schema.ts","./src/parse-openapi.ts","./src/zod-codegen.ts"],"version":"6.0.3"}
|