Skip to content

Commit 9e833e2

Browse files
bordumbclaude
andcommitted
feat: move auths-oidc-bridge into public workspace
Move the OIDC bridge crate from auths-cloud into the public auths workspace at Layer 2. Uses static pre-generated RSA keys in tests to avoid slow runtime key generation (test suite: 99s → 0.4s). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5b7ad30 commit 9e833e2

29 files changed

Lines changed: 5602 additions & 54 deletions

Cargo.lock

Lines changed: 801 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ members = [
1515
"crates/auths-infra-http",
1616
"crates/auths-storage",
1717
"crates/auths-keri",
18+
"crates/auths-oidc-bridge",
1819
"crates/xtask",
1920
]
2021
exclude = [
@@ -47,4 +48,5 @@ auths-crypto = { path = "crates/auths-crypto", version = "0.0.1-rc.4", default-f
4748
auths-sdk = { path = "crates/auths-sdk", version = "0.0.1-rc.4" }
4849
auths-infra-git = { path = "crates/auths-infra-git", version = "0.0.1-rc.4" }
4950
auths-infra-http = { path = "crates/auths-infra-http", version = "0.0.1-rc.4" }
51+
auths-oidc-bridge = { path = "crates/auths-oidc-bridge", version = "0.0.1-rc.6" }
5052
auths-storage = { path = "crates/auths-storage", version = "0.0.1-rc.4" }

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Auths
22

3-
Decentralized identity for developers. One identity, multiple devices, Git-native storage.
3+
Decentralized identity for individuals, AI agents, and their organizations.
4+
5+
One identity, multiple devices, Git-native storage.
46

57
## Install
68

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
[package]
2+
name = "auths-oidc-bridge"
3+
version.workspace = true
4+
edition = "2024"
5+
rust-version = "1.93"
6+
authors = ["Auths Contributors"]
7+
description = "OIDC bridge: exchanges KERI attestation chains for cloud-provider JWTs"
8+
license.workspace = true
9+
repository.workspace = true
10+
homepage.workspace = true
11+
keywords = ["identity", "oidc", "jwt", "keri"]
12+
categories = ["authentication", "web-programming"]
13+
14+
[features]
15+
default = []
16+
github-oidc = ["dep:reqwest"]
17+
18+
[[bin]]
19+
name = "auths-oidc-bridge"
20+
path = "src/main.rs"
21+
22+
[lib]
23+
name = "auths_oidc_bridge"
24+
path = "src/lib.rs"
25+
26+
[dependencies]
27+
auths-telemetry.workspace = true
28+
auths-verifier = { workspace = true, features = ["native"] }
29+
30+
anyhow = "1"
31+
axum = "0.8"
32+
governor = { version = "0.8", features = ["dashmap"] }
33+
base64.workspace = true
34+
chrono = { version = "0.4", features = ["serde"] }
35+
hex = "0.4"
36+
jsonwebtoken = "9"
37+
rand = "0.8"
38+
rsa = { version = "0.9", features = ["pem"] }
39+
serde = { version = "1", features = ["derive"] }
40+
serde_json = "1"
41+
sha2 = "0.10"
42+
thiserror.workspace = true
43+
tokio.workspace = true
44+
tower-http = { version = "0.6", features = ["trace", "cors"] }
45+
tracing = "0.1"
46+
uuid.workspace = true
47+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"], optional = true }
48+
49+
[dev-dependencies]
50+
auths-crypto.workspace = true
51+
tower = { version = "0.5", features = ["util"] }
52+
http-body-util = "0.1"
53+
tempfile = "3"
54+
ring.workspace = true
55+
json-canon = "0.1"
56+
bs58.workspace = true
57+
base64.workspace = true
58+
chrono = "0.4"
59+
hex = "0.4"
60+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
61+
aws-config = { version = "1", features = ["behavior-version-latest"] }
62+
aws-sdk-sts = "1"

crates/auths-oidc-bridge/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# auths-oidc-bridge
2+
3+
OIDC bridge that exchanges KERI attestation chains for short-lived RS256 JWTs consumable by cloud providers (AWS STS, GCP Workload Identity, Azure AD).
4+
5+
## How it works
6+
7+
1. Client POSTs an attestation chain + root public key to `/token`
8+
2. Bridge verifies the chain via `auths-verifier`
9+
3. Bridge issues a signed RS256 JWT with OIDC-standard claims
10+
4. Cloud provider validates the JWT against `/.well-known/jwks.json`
11+
12+
## Features
13+
14+
- Token exchange endpoint with attestation chain verification
15+
- JWKS and OpenID Configuration discovery endpoints
16+
- Key rotation with dual-key JWKS support
17+
- Rate limiting per identity prefix
18+
- Optional GitHub Actions OIDC cross-referencing (`github-oidc` feature)
19+
20+
## Feature flags
21+
22+
| Flag | Description |
23+
|------|-------------|
24+
| `github-oidc` | Enables GitHub Actions OIDC token verification and cross-referencing |
25+
26+
## License
27+
28+
Apache-2.0
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
//! Audience format detection and validation for cloud providers.
2+
3+
use crate::error::BridgeError;
4+
5+
/// Detected cloud provider based on audience format.
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub enum AudienceKind {
8+
/// AWS STS (e.g. `sts.amazonaws.com`).
9+
Aws,
10+
/// GCP Workload Identity Federation.
11+
Gcp,
12+
/// Azure AD / Entra ID.
13+
Azure,
14+
/// Unknown or custom audience.
15+
Custom,
16+
}
17+
18+
impl AudienceKind {
19+
/// Returns the provider name as a string, or `None` for `Custom`.
20+
pub fn provider_name(self) -> Option<&'static str> {
21+
match self {
22+
Self::Aws => Some("aws"),
23+
Self::Gcp => Some("gcp"),
24+
Self::Azure => Some("azure"),
25+
Self::Custom => None,
26+
}
27+
}
28+
}
29+
30+
/// Controls how audience format mismatches are handled.
31+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32+
pub enum AudienceValidation {
33+
/// Log warnings on format mismatch but allow the request (default).
34+
#[default]
35+
Warn,
36+
/// Reject requests with audience format mismatches.
37+
Strict,
38+
/// Skip audience format validation entirely.
39+
None,
40+
}
41+
42+
impl AudienceValidation {
43+
/// Parse from a string value (for env var parsing).
44+
pub fn from_str_value(s: &str) -> Option<Self> {
45+
match s.to_lowercase().as_str() {
46+
"warn" => Some(Self::Warn),
47+
"strict" => Some(Self::Strict),
48+
"none" => Some(Self::None),
49+
_ => Option::None,
50+
}
51+
}
52+
}
53+
54+
/// Detect the cloud provider from the audience string and validate the format.
55+
///
56+
/// Returns the detected `AudienceKind`. In `Strict` mode, returns an error
57+
/// for format mismatches. In `Warn` mode, logs warnings. In `None` mode,
58+
/// skips all validation.
59+
pub fn validate_audience_format(
60+
audience: &str,
61+
mode: &AudienceValidation,
62+
) -> Result<AudienceKind, BridgeError> {
63+
let kind = detect_audience_kind(audience);
64+
65+
if *mode == AudienceValidation::None {
66+
return Ok(kind);
67+
}
68+
69+
// Check for GCP format mismatches
70+
if kind == AudienceKind::Gcp && !is_valid_gcp_audience(audience) {
71+
let msg = format!(
72+
"GCP audience format mismatch: expected \
73+
https://iam.googleapis.com/projects/{{NUMBER}}/locations/global/\
74+
workloadIdentityPools/{{POOL}}/providers/{{PROVIDER}}, got: {audience}"
75+
);
76+
match mode {
77+
AudienceValidation::Strict => {
78+
return Err(BridgeError::InvalidRequest(msg));
79+
}
80+
AudienceValidation::Warn => {
81+
tracing::warn!("{msg}");
82+
}
83+
AudienceValidation::None => unreachable!(),
84+
}
85+
}
86+
87+
tracing::info!(audience = audience, kind = ?kind, "audience format detected");
88+
Ok(kind)
89+
}
90+
91+
/// Detect the audience kind from the audience string.
92+
pub fn detect_audience_kind(audience: &str) -> AudienceKind {
93+
if audience.contains("amazonaws.com") {
94+
AudienceKind::Aws
95+
} else if audience.starts_with("https://iam.googleapis.com/") {
96+
AudienceKind::Gcp
97+
} else if audience.starts_with("api://") || looks_like_guid(audience) {
98+
AudienceKind::Azure
99+
} else {
100+
AudienceKind::Custom
101+
}
102+
}
103+
104+
/// Check if a GCP audience matches the expected Workload Identity Federation format.
105+
///
106+
/// Expected: `https://iam.googleapis.com/projects/{NUMBER}/locations/global/workloadIdentityPools/{POOL}/providers/{PROVIDER}`
107+
fn is_valid_gcp_audience(audience: &str) -> bool {
108+
let Some(rest) = audience.strip_prefix("https://iam.googleapis.com/projects/") else {
109+
return false;
110+
};
111+
112+
// Expected: {NUMBER}/locations/global/workloadIdentityPools/{POOL}/providers/{PROVIDER}
113+
let parts: Vec<&str> = rest
114+
.splitn(2, "/locations/global/workloadIdentityPools/")
115+
.collect();
116+
if parts.len() != 2 {
117+
return false;
118+
}
119+
120+
let project_number = parts[0];
121+
if project_number.is_empty() || !project_number.chars().all(|c| c.is_ascii_digit()) {
122+
return false;
123+
}
124+
125+
// Expected: {POOL}/providers/{PROVIDER}
126+
let provider_parts: Vec<&str> = parts[1].splitn(2, "/providers/").collect();
127+
if provider_parts.len() != 2 {
128+
return false;
129+
}
130+
131+
let pool_id = provider_parts[0];
132+
let provider_id = provider_parts[1];
133+
134+
!pool_id.is_empty() && !provider_id.is_empty()
135+
}
136+
137+
/// Check if a string looks like a GUID (8-4-4-4-12 hex digits).
138+
fn looks_like_guid(s: &str) -> bool {
139+
let parts: Vec<&str> = s.split('-').collect();
140+
parts.len() == 5
141+
&& parts[0].len() == 8
142+
&& parts[1].len() == 4
143+
&& parts[2].len() == 4
144+
&& parts[3].len() == 4
145+
&& parts[4].len() == 12
146+
&& parts
147+
.iter()
148+
.all(|p| p.chars().all(|c| c.is_ascii_hexdigit()))
149+
}

0 commit comments

Comments
 (0)