Skip to content

Commit 0d2da15

Browse files
authored
fix: inject security information for server generated code (#88)
1 parent ec4aca1 commit 0d2da15

8 files changed

Lines changed: 594 additions & 6 deletions

File tree

crates/oapi-codegen/src/emit/axum.rs

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use quote::format_ident;
55
use quote::quote;
66

77
use crate::emit::doc_attr;
8+
use crate::emit::doc_lines;
89
use crate::emit::emit_type;
910
use crate::error::Result;
1011
use crate::ir::BodyKind;
@@ -23,6 +24,8 @@ use crate::ir::ResponseCase;
2324
use crate::ir::ResponseHeader;
2425
use crate::ir::ResponseStatus;
2526
use crate::ir::RustType;
27+
use crate::ir::SecurityScheme;
28+
use crate::ir::SecuritySchemeKind;
2629
use crate::ir::Service;
2730
use crate::naming::operations::axum_handler_name;
2831

@@ -127,7 +130,7 @@ fn emit_trait(service: &Service) -> Result<TokenStream> {
127130
let mut methods = Vec::with_capacity(service.operations.len());
128131
for operation in &service.operations {
129132
let name = operation.name.to_token();
130-
let doc = doc_attr(&operation.doc);
133+
let doc = method_doc(operation, &service.security_schemes);
131134
let response = operation.response_enum.to_token();
132135
let args = emit_method_args(operation)?;
133136
methods.push(quote! {
@@ -143,6 +146,74 @@ fn emit_trait(service: &Service) -> Result<TokenStream> {
143146
});
144147
}
145148

149+
/// The doc comment of a trait method: the operation's own description, then the
150+
/// security the document names for it.
151+
///
152+
/// The generator emits no check for that security, because verifying a
153+
/// credential needs application knowledge it does not have: which key, which
154+
/// issuer, and which claim names which user. Naming the schemes is what it can
155+
/// do, so an implementer does not have to read the document to tell a public
156+
/// operation from a protected one.
157+
fn method_doc(operation: &Operation, schemes: &[SecurityScheme]) -> TokenStream {
158+
if operation.security.is_empty() {
159+
return doc_attr(&operation.doc);
160+
}
161+
162+
let mut lines: Vec<String> = Vec::new();
163+
if let Some(text) = &operation.doc {
164+
lines.push(text.clone());
165+
lines.push(String::new());
166+
}
167+
lines.push("# Security".to_owned());
168+
lines.push(String::new());
169+
lines.push("The document names these security schemes for this operation:".to_owned());
170+
lines.push(String::new());
171+
for key in &operation.security {
172+
lines.push(format!("- {}", requirement_line(key, schemes)));
173+
}
174+
lines.push(String::new());
175+
if operation.security.len() > 1 {
176+
// `security::required_keys` unions the alternatives and the
177+
// conjunctions, so past one key the list no longer says which it was.
178+
lines.push(
179+
"This list is the union of every alternative the document gives, so it may be a choice between schemes rather than all of them. Read `security` in the document for the exact rule."
180+
.to_owned(),
181+
);
182+
lines.push(String::new());
183+
}
184+
lines.push(
185+
"This generator emits no check. Enforce it in a layer around the router: this method receives only the parameters the operation declares, not the credential."
186+
.to_owned(),
187+
);
188+
return doc_lines(&lines);
189+
}
190+
191+
/// One security scheme, named and located.
192+
///
193+
/// Where the credential sits is the part a server needs, because it has to read
194+
/// the credential itself.
195+
fn requirement_line(key: &str, schemes: &[SecurityScheme]) -> String {
196+
let Some(scheme) = schemes.iter().find(|scheme| return scheme.key == key) else {
197+
// The document names a scheme it never declares. The client rejects
198+
// that; a server has nothing to reject, so the doc says what it knows.
199+
return format!("`{key}`, which `components.securitySchemes` does not declare");
200+
};
201+
let location = match &scheme.kind {
202+
SecuritySchemeKind::HttpBearer => "a bearer token in the `Authorization` header".to_owned(),
203+
SecuritySchemeKind::HttpBasic => "basic credentials in the `Authorization` header".to_owned(),
204+
SecuritySchemeKind::ApiKeyHeader(name) => format!("an API key in the `{name}` header"),
205+
SecuritySchemeKind::ApiKeyQuery(name) => format!("an API key in the `{name}` query parameter"),
206+
SecuritySchemeKind::ApiKeyCookie(name) => format!("an API key in the `{name}` cookie"),
207+
// The reason held here is written for the client, which refuses to send
208+
// such a credential. A server reads credentials rather than sending
209+
// them, so the key alone is what this can honestly state.
210+
SecuritySchemeKind::Unsupported(_) => {
211+
return format!("`{key}`, a scheme this generator has no built-in support for");
212+
}
213+
};
214+
return format!("`{key}`: {location}");
215+
}
216+
146217
/// The typed arguments (path parameters, query struct, header struct, then JSON
147218
/// body) of an operation method.
148219
fn emit_method_args(operation: &Operation) -> Result<Vec<TokenStream>> {
@@ -892,3 +963,64 @@ fn request_content_type_test(kind: BodyKind) -> TokenStream {
892963
}
893964
};
894965
}
966+
967+
#[cfg(test)]
968+
mod tests {
969+
use super::*;
970+
use crate::naming::Case;
971+
use crate::naming::to_ident;
972+
973+
fn scheme(key: &str, kind: SecuritySchemeKind) -> SecurityScheme {
974+
return SecurityScheme {
975+
key: key.to_owned(),
976+
field: to_ident(key, Case::Snake),
977+
kind,
978+
doc: None,
979+
};
980+
}
981+
982+
#[test]
983+
fn every_scheme_kind_says_where_the_credential_sits() {
984+
let schemes = vec![
985+
scheme("bearerAuth", SecuritySchemeKind::HttpBearer),
986+
scheme("basicAuth", SecuritySchemeKind::HttpBasic),
987+
scheme("headerKey", SecuritySchemeKind::ApiKeyHeader("X-API-Key".to_owned())),
988+
scheme("queryKey", SecuritySchemeKind::ApiKeyQuery("api_key".to_owned())),
989+
scheme("cookieKey", SecuritySchemeKind::ApiKeyCookie("SESSION".to_owned())),
990+
];
991+
let cases = [
992+
(
993+
"bearerAuth",
994+
"`bearerAuth`: a bearer token in the `Authorization` header",
995+
),
996+
(
997+
"basicAuth",
998+
"`basicAuth`: basic credentials in the `Authorization` header",
999+
),
1000+
("headerKey", "`headerKey`: an API key in the `X-API-Key` header"),
1001+
("queryKey", "`queryKey`: an API key in the `api_key` query parameter"),
1002+
("cookieKey", "`cookieKey`: an API key in the `SESSION` cookie"),
1003+
];
1004+
for (key, expected) in cases {
1005+
assert_eq!(requirement_line(key, &schemes), expected);
1006+
}
1007+
}
1008+
1009+
/// The client rejects a scheme the document never declares. A server has
1010+
/// nothing to reject, so the note has to stand on the key alone.
1011+
#[test]
1012+
fn an_undeclared_scheme_is_still_named() {
1013+
let line = requirement_line("ghost", &[]);
1014+
assert_eq!(line, "`ghost`, which `components.securitySchemes` does not declare");
1015+
}
1016+
1017+
#[test]
1018+
fn an_unsupported_scheme_drops_the_client_side_reason() {
1019+
let schemes = vec![scheme(
1020+
"oauth2",
1021+
SecuritySchemeKind::Unsupported("the client cannot send this".to_owned()),
1022+
)];
1023+
let line = requirement_line("oauth2", &schemes);
1024+
assert_eq!(line, "`oauth2`, a scheme this generator has no built-in support for");
1025+
}
1026+
}

crates/oapi-codegen/src/emit/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,27 @@ pub(crate) fn doc_attr(doc: &Option<String>) -> TokenStream {
224224
return tokens;
225225
}
226226

227+
/// Render one doc attribute per line, which rustdoc reads as one comment.
228+
///
229+
/// A blank line stays blank, so a caller can separate paragraphs with one. An
230+
/// entry that already holds line breaks, such as a multi-line `description` from
231+
/// the document, is split on them: a `#[doc]` carrying a `\n` prints as a
232+
/// `/** */` block, which would sit unevenly among its `///` siblings.
233+
pub(crate) fn doc_lines(lines: &[String]) -> TokenStream {
234+
let attrs = lines.iter().flat_map(|entry| return entry.split('\n')).map(|line| {
235+
// Leading space matches the `/// text` desugaring rustfmt produces. A
236+
// blank line takes none, so no trailing space reaches the output.
237+
let trimmed = line.trim_end();
238+
let spaced = if trimmed.is_empty() {
239+
String::new()
240+
} else {
241+
format!(" {trimmed}")
242+
};
243+
return quote! { #[doc = #spaced] };
244+
});
245+
return quote! { #(#attrs)* };
246+
}
247+
227248
/// Render a Rust type expression.
228249
pub(crate) fn emit_type(ty: &RustType) -> Result<TokenStream> {
229250
let tokens = match ty {

crates/oapi-codegen/src/lower/security.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
//! Lowering OpenAPI security schemes and requirements into the client IR.
1+
//! Lowering OpenAPI security schemes and requirements into the IR.
22
//!
33
//! Only the shared lowering pass runs here: it catalogues the document's
44
//! `components.securitySchemes` and resolves each operation's *effective*
5-
//! security requirement. The server emitter ignores the result (server-side
6-
//! auth is not generated yet). The client emitter turns supported schemes into
7-
//! credential fields and rejects operations that require an
8-
//! [`crate::ir::SecuritySchemeKind::Unsupported`] scheme.
5+
//! security requirement. Both emitters read the result. The client turns
6+
//! supported schemes into credential fields and rejects operations that require
7+
//! an [`crate::ir::SecuritySchemeKind::Unsupported`] scheme. The server names
8+
//! the requirement in each trait method's doc comment, but emits no check: only
9+
//! the application knows how to verify a credential.
910
1011
use openapiv3::APIKeyLocation;
1112
use openapiv3::ReferenceOr;

crates/oapi-codegen/tests/coverage.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ const SERVER_FIXTURES: &[&str] = &[
432432
"server_prune",
433433
"server_filtering",
434434
"server_urls",
435+
"server_auth",
435436
];
436437

437438
/// Server fixtures whose generation must fail with a documented error, covering
@@ -733,6 +734,7 @@ server_generated_tests!(
733734
server_prune,
734735
server_filtering,
735736
server_urls,
737+
server_auth,
736738
);
737739

738740
/// The server `#[test]`s must cover exactly the supported server fixtures.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Authenticated server
4+
version: 1.0.0
5+
security:
6+
- bearerAuth: []
7+
paths:
8+
/profile:
9+
get:
10+
operationId: getProfile
11+
summary: Fetch the caller's profile, which the global bearer scheme protects.
12+
responses:
13+
"200":
14+
description: The caller's profile.
15+
content:
16+
application/json:
17+
schema:
18+
$ref: "#/components/schemas/Message"
19+
/public:
20+
get:
21+
operationId: getPublic
22+
summary: Fetch a public resource, which needs no credential.
23+
security: []
24+
responses:
25+
"200":
26+
description: The public resource.
27+
content:
28+
application/json:
29+
schema:
30+
$ref: "#/components/schemas/Message"
31+
/reports:
32+
get:
33+
operationId: getReports
34+
# Two schemes at once, so the note lists both.
35+
security:
36+
- apiKeyHeader: []
37+
basicAuth: []
38+
responses:
39+
"200":
40+
description: The reports.
41+
content:
42+
application/json:
43+
schema:
44+
$ref: "#/components/schemas/Message"
45+
/session:
46+
get:
47+
operationId: getSession
48+
# A description over several lines. The trait method splits it into `///`
49+
# lines, because a `/** */` block would sit unevenly among the `///` lines
50+
# the security section adds. `GetSessionResponse` keeps the block, since
51+
# nothing follows its description.
52+
description: |
53+
Fetch session data, which an API key cookie protects.
54+
55+
The cookie is set at login and expires with the session.
56+
security:
57+
- apiKeyCookie: []
58+
responses:
59+
"200":
60+
description: The session data.
61+
content:
62+
application/json:
63+
schema:
64+
$ref: "#/components/schemas/Message"
65+
/search:
66+
get:
67+
operationId: search
68+
summary: Search, which an API key query parameter protects.
69+
security:
70+
- apiKeyQuery: []
71+
responses:
72+
"200":
73+
description: The search results.
74+
content:
75+
application/json:
76+
schema:
77+
$ref: "#/components/schemas/Message"
78+
/audit:
79+
get:
80+
operationId: getAudit
81+
summary: Fetch the audit log, which OAuth2 protects.
82+
# The client refuses to send an OAuth2 credential. A server reads
83+
# credentials instead of sending them, so this only names the scheme.
84+
security:
85+
- oauth2: []
86+
responses:
87+
"200":
88+
description: The audit log.
89+
content:
90+
application/json:
91+
schema:
92+
$ref: "#/components/schemas/Message"
93+
components:
94+
securitySchemes:
95+
bearerAuth:
96+
type: http
97+
scheme: bearer
98+
bearerFormat: JWT
99+
description: A JSON Web Token passed as a bearer credential.
100+
basicAuth:
101+
type: http
102+
scheme: basic
103+
description: HTTP basic username and password.
104+
apiKeyHeader:
105+
type: apiKey
106+
in: header
107+
name: X-API-Key
108+
description: API key sent in a request header.
109+
apiKeyQuery:
110+
type: apiKey
111+
in: query
112+
name: api_key
113+
description: API key sent as a query parameter.
114+
apiKeyCookie:
115+
type: apiKey
116+
in: cookie
117+
name: SESSION
118+
description: API key sent as a cookie.
119+
oauth2:
120+
type: oauth2
121+
flows:
122+
clientCredentials:
123+
tokenUrl: https://example.com/token
124+
scopes: {}
125+
schemas:
126+
Message:
127+
type: object
128+
required:
129+
- text
130+
properties:
131+
text:
132+
type: string

crates/oapi-codegen/tests/generated.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ mod generated {
110110
pub mod server_text_body;
111111
#[path = "server_urls.rs"]
112112
pub mod server_urls;
113+
114+
#[path = "server_auth.rs"]
115+
pub mod server_auth;
113116
#[path = "server_xfile_refs.rs"]
114117
pub mod server_xfile_refs;
115118
#[path = "string_enum.rs"]

0 commit comments

Comments
 (0)