Compare commits

...
Author SHA1 Message Date
Johanna LarssonandTangled c0caa93228 Dev compose improvements
1. Set the max connections to 20, I frequently see

db-1            | 2026-08-23 08:57:02.784 UTC [12641] FATAL:  sorry, too many clients already

2. Add a wildcard route in Traefik to serve DID documents locally.

3. Expose the PLC port locally so I can point an app at `http://localhost:2582` and be able to do full OAuth flows.
2026-08-23 09:50:08 +00:00
blooym.devandTangled 3ade3d10c1 docs: clarify the postgres seq fix command
There was a syntax error in this command, so I updated the doc to clarify it and also show it needs an integer, not a string like it implied before.
2026-08-21 17:35:46 +00:00
Jack PlattenandTangled 0189aa9f96 Update config commit to create new round 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled d495d7d729 Use crate::types::queuedcomms export
also generate example.toml
2026-08-21 16:16:45 +00:00
73cb89c9b7 resolve review feedback.
- eliminates panic opportunity on receiving email
- strict enum
- added unit test for ensuring that atmos headers don't leak onto
  directmx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:16:45 +00:00
Jack PlattenandTangled ecb7934a20 fix: fix missing test failure 2026-08-21 16:16:45 +00:00
Jack PlattenandTangled 9edc7dcdd8 comms: add comail.at category support
Adds a defaulted to off option to add the `X-Atmos-Category` headers
to emails sent via smarthost, for proper categorization by comail.

Category breakdown is as follows:

verification: EmailVerification, ChannelVerification, ChannelVerified,
  MigrationVerification, LegacyLoginAlert, EmailUpdate, PlcOperation,
  AccountDeletion
password-reset: PasswordReset, PasskeyRecovery
mfa-otp: TwoFactorCode
bulk: Welcome
untagged: AdminEmail
2026-08-21 16:16:45 +00:00
Matan KushnerandTangled 479fa3ed22 fix: require DPoP for loopback clients 2026-08-21 11:41:29 +00:00
Louis EscherandTangled aa815931e0 Update lib.rs 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 0ce725174d fix: DID length test, service test, cloning, dead code (should be it!) 2026-08-20 08:21:53 +00:00
Louis EscherandTangled dae3cc7e08 fix: aud fragment matching 2026-08-20 08:21:53 +00:00
Louis EscherandTangled b9e7955606 fix: pass exp to token creation 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 32c58b1d0b fix: make thingy allow list 2026-08-20 08:21:53 +00:00
Louis EscherandTangled 1b5a2b319c fix: getServiceAuth aud parsing 2026-08-20 08:21:53 +00:00
LewisandTangled ed3d129594 just: clippy over all targets, lint the bsky-off build
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 8d0b6f8322 cache: DID, SSO, & OAuth client metadata caches onto shared cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0fc577316e lexicon: schema docs & negative results via cluster cache
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 52d5236e89 plc: dedup fetch paths, cache TTL from config
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0274f19d75 auth: EmailTokenPurpose from tranquil-types, shared cache key fns, MemoryCache in tests
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 135912194d types: HttpUrl newtypes, shared cache key/JSON helpers
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
LewisandTangled 0b8787d1de pds: compile bsky-specific proxy, CORS, & validation out under bsky features
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 17:15:23 +00:00
Lewis 18455f54f2 api: moorfc compliance endpoint toggle
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-08-16 19:58:24 +03:00
Louis EscherandTangled ce2f05b9d4 fix: make coverage triple state instead of boolean 2026-08-13 16:09:03 +00:00
Louis EscherandTangled c88f69f31d fix: Address PR review 2026-08-13 16:09:03 +00:00
Louis EscherandTangled b3c314ce66 fix: Address review comments 2026-08-13 16:09:03 +00:00
Louis EscherandTangled 434079a732 feat: compress large token scopes with brotli 2026-08-13 16:09:03 +00:00
Louis EscherandTangled a5a2f30bbe fix: Collapse action parameters for repo scopes
TODO: Still missing tests!
2026-08-13 16:09:03 +00:00
Jack PlattenandTangled dc2fbe6654 chore: use JSON array for healthcheck now that container is distroless 2026-08-13 05:35:23 +00:00
Johanna LarssonandTangled bc751b0ee2 Bring back thing that made yubikey work 2026-08-08 15:41:29 +00:00
Johanna LarssonandTangled 9e78206cf4 Switch back to SecurityKey, remove hint 2026-08-08 15:41:29 +00:00
Johanna LarssonandTangled 779dc1b985 Replace SecurityKey with Passkey 2026-08-08 15:41:29 +00:00
Edmund EdgarandTangled 1dc0c40206 fix: don't require a server-custodied rotation key
Remove the requirement that the user's did can be controlled by the
server rotation key and signing key. This was preventing users who
didn't want to trust the PDS with their rotation keys from activating
their accounts. Errors are removed but we issue debug messages.
2026-07-26 11:45:09 +03:00
nelind 72fa88d79a chore: bump version to 0.6.6 2026-07-26 00:25:56 +02:00
nelindandTangled 596b9b15fd feat(docs): explain our definition of "correct PDS implementation" 2026-07-25 18:31:06 +03:00
nelindandTangled 59934cc184 feat: add bsky and bsky-support cargo features to manage bsky specific code 2026-07-25 18:31:06 +03:00
nelindandTangled 34a47e6e5a chore: clean up Cargo.toml files with cargo-shear and remove default feature on lib crates 2026-07-25 18:31:06 +03:00
LewisandTangled aca78bb8d3 scopes: tweak tests, add translations
Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>
2026-07-25 11:08:27 +03:00
115 changed files with 4033 additions and 1538 deletions
+55
View File
@@ -4,6 +4,7 @@
In order of importance:
- If your change involves how Tranquil implements atproto make sure its correct! See more below.
- **You must run your change! Every contribution that says "here's xyz. untested." does not help the project.**
- Relevant tests to your PR must pass. The whole suite doesn't have to be proven to have run, because there are a *ton* of tests and they're quite heavy, but hopefully there are existing tests for whatever you're PRing, and if there aren't, please add those too.
- Run cargo fmt :P
@@ -16,6 +17,60 @@ Things that would also be nice but aren't like, a pain in our side:
- Big changes should be stacked PRs that are broken up into digestible pieces. Those stacked PRs should hopefully be able to be merged individually if necessary.
### How we define a "correct" PDS implementation
The atproto specs are notoriously imprecise, ambiguious,
lacks specifications for large parts of the protocol and network (even including what implementing a PDS entails!)
and is generally none specific.
This is bad.
We won't waste time here describing all the ways in which that is problematic,
the important thing for Tranquil is that this means that "follows spec" is not sufficient to describe a "correct" PDS implementation.
Thus we need to come up with a description of "correct".
In order of importance the following rules describe what "correct" means for Tranquil:
- The specs take precedence.
If the spec *is* specific enough then follow it.
Even if the reference implementation doesn't.
- If the specs aren't sufficiently specific
rely on the reference implementation, potential supporting documents or discussions,
and/or community sentiment or common sense.
If the matter is still debated and/or PBCs opinion differs from community sentiment we generally side with the community.
- Examples here include what features and APIs to implement,
here we look at what the reference implementation implements
as well as https://github.com/bluesky-social/atproto/discussions/2350 as a supporting document.
Another example is whether `include` scopes are allowed to use a `*` `aud` parameter.
Discussion here has happened in https://github.com/bluesky-social/atproto/issues/4490.
PBC has voiced an opinion that this should be disallowed,
community sentiment seems to strongly lean to allowing it. Tranquil allows it.
- Please mark locations like this with a `// SPECAMB: ...` comment explaining the ambiguity
and what parts of the reference implementation and/or supporting documents have been used as reference.
- If the reference implementation has behaviour that is only ever relevant for the Bluesky application.
Implementions of such behaviour **must** be gated behind a `bsky-support` cargo feature of the implementing crate.
- Examples here include bluesky feedgen specific service proxying behaviour,
the `app.bsky.actor.getPreferences` and `app.bsky.actor.putPreferences` APIs,
and special handling of the `X-BSKY-TOPICS` HTTP header during service proxying.
- Please add a comment next to these implementations with an explanation of the behaviour.
- Most of these behaviours are required for proper functioning of the official Bluesky client, though not all.
If the behaviour isn't required for the official client consider not implementing it.
- One such behaviour that we have a *hard rule* to never implement is default proxying to a configured Bluesky appview
for `app.bsky.*` APIs and as fallback for `com.atproto.repo.getRecord`.
Many third-party Bluesky clients rely on this behaviour, the official client used to do the same but does not anymore.
Third-party clients breaking because they don't specify an `atproto-proxy` header is thus *not* a Tranquil bug but a bug in said clients.
- Bluesky is the only application that will ever recieve application specific behaviour like this.
It does so only because such a big section of atproto usage is Bluesky
and because Bluesky is the only application that can practically rely on application specific behaviour.
Application specific behaviour for other applications may still be added to Tranquil if such behaviour is a Tranquil feature,
for example for Tranquils rudimentary banned content moderation feature,
and not something said application relies on for proper functioning.
There is bound to be edge cases that these rules don't fully cover.
Here common sense, community sentiment, furthering the goals of atproto itself, and ultimately maintainer opinion take precedence over support for any individual application.
Even Bluesky.
The rules above are meant to capture Tranquils goals of being correct while being community oriented and avoiding as much "Bluesky-defaultism" as possible.
Tranquil is a community atproto PDS, *not* a company-led Bluesky (or other atproto app) PDS.
See also "Tranquil & the world" in docs/1_WELCOME_TO_TRANQUIL_PDS.md.
## Local Development
### Prerequisites
Generated
+70 -76
View File
@@ -105,6 +105,21 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -1250,6 +1265,27 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "brotli"
version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bs58"
version = "0.5.1"
@@ -2486,7 +2522,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -7630,9 +7665,8 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
"backon",
"base32",
@@ -7666,27 +7700,25 @@ dependencies = [
"tokio",
"tracing",
"tranquil-config",
"tranquil-db",
"tranquil-db-traits",
"tranquil-lexicon",
"tranquil-pds",
"tranquil-scopes",
"tranquil-signal",
"tranquil-types",
"urlencoding",
"uuid",
"webauthn-rs",
"zip",
]
[[package]]
name = "tranquil-auth"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"base32",
"base64 0.22.1",
"bcrypt",
"brotli",
"chrono",
"hmac",
"k256",
@@ -7705,7 +7737,7 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7720,7 +7752,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7734,7 +7766,6 @@ dependencies = [
"rsa",
"secrecy",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -7746,15 +7777,14 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"confique",
"serde",
]
[[package]]
name = "tranquil-crypto"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7770,7 +7800,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"chrono",
@@ -7787,7 +7817,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7803,18 +7833,20 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"bytes",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"tranquil-config",
"tranquil-types",
]
[[package]]
name = "tranquil-lexicon"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"chrono",
"futures",
@@ -7826,15 +7858,15 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"unicode-segmentation",
"urlencoding",
"wiremock",
]
[[package]]
name = "tranquil-oauth"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
@@ -7851,13 +7883,14 @@ dependencies = [
"sqlx",
"tokio",
"tracing",
"tranquil-infra",
"tranquil-types",
"uuid",
]
[[package]]
name = "tranquil-oauth-server"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"axum",
@@ -7882,6 +7915,7 @@ dependencies = [
"tranquil-config",
"tranquil-crypto",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-pds",
"tranquil-scopes",
"tranquil-types",
@@ -7892,7 +7926,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7904,7 +7938,6 @@ dependencies = [
"base32",
"base64 0.22.1",
"bcrypt",
"bs58",
"bytes",
"chrono",
"ciborium",
@@ -7920,7 +7953,6 @@ dependencies = [
"hmac",
"http 1.4.0",
"image",
"infer",
"ipld-core",
"iroh-car",
"jacquard-common",
@@ -7962,9 +7994,9 @@ dependencies = [
"tranquil-cache",
"tranquil-comms",
"tranquil-config",
"tranquil-crypto",
"tranquil-db",
"tranquil-db-traits",
"tranquil-infra",
"tranquil-lexicon",
"tranquil-oauth",
"tranquil-oauth-server",
@@ -7981,12 +8013,11 @@ dependencies = [
"webauthn-rs",
"webauthn-rs-proto",
"wiremock",
"zip",
]
[[package]]
name = "tranquil-repo"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"bytes",
"cid",
@@ -7998,7 +8029,7 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"backon",
@@ -8027,7 +8058,7 @@ dependencies = [
[[package]]
name = "tranquil-scopes"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"axum",
"futures",
@@ -8044,7 +8075,7 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"arc-swap",
"axum",
@@ -8081,7 +8112,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"chrono",
@@ -8089,7 +8120,6 @@ dependencies = [
"futures",
"presage",
"rand 0.9.2",
"serde",
"serde_json",
"sqlx",
"tempfile",
@@ -8097,14 +8127,13 @@ dependencies = [
"tokio",
"tokio-util",
"tracing",
"tranquil-signal",
"url",
"uuid",
]
[[package]]
name = "tranquil-storage"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"aws-config",
@@ -8121,7 +8150,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"async-trait",
"bytes",
@@ -8161,7 +8190,6 @@ dependencies = [
"tranquil-db",
"tranquil-db-traits",
"tranquil-oauth",
"tranquil-repo",
"tranquil-store",
"tranquil-types",
"uuid",
@@ -8170,7 +8198,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"anyhow",
"axum",
@@ -8192,17 +8220,21 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.5"
version = "0.6.6"
dependencies = [
"base64 0.22.1",
"chrono",
"cid",
"jacquard-common",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid",
]
@@ -8255,12 +8287,6 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -9462,20 +9488,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "7.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
dependencies = [
"crc32fast",
"flate2",
"indexmap 2.13.0",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zkcredential"
version = "0.1.0"
@@ -9524,30 +9536,12 @@ dependencies = [
"zkcredential",
]
[[package]]
name = "zlib-rs"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
+2 -3
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.5"
version = "0.6.6"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -47,7 +47,6 @@ tranquil-db = { path = "crates/tranquil-db" }
tranquil-ripple = { path = "crates/tranquil-ripple" }
tranquil-lexicon = { path = "crates/tranquil-lexicon" }
tranquil-pds = { path = "crates/tranquil-pds" }
tranquil-server = { path = "crates/tranquil-server" }
tranquil-sync = { path = "crates/tranquil-sync" }
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
tranquil-api = { path = "crates/tranquil-api" }
@@ -138,11 +137,11 @@ tower-layer = "0.3"
tracing = "0.1"
tracing-subscriber = "0.3"
urlencoding = "2.1"
url = "2.5"
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
webauthn-rs-proto = "0.5"
x509-parser = "0.18"
zip = { version = "7.0", default-features = false, features = ["deflate"] }
ciborium = "0.2"
ctor = "0.6"
+1
View File
@@ -10,6 +10,7 @@ dir = "/app/frontend/public"
[database]
url = "postgres://postgres:postgres@db:5432/pds"
max_connections = 20
[storage]
path = "/var/lib/tranquil-pds/blobs"
+4 -4
View File
@@ -8,13 +8,10 @@ license.workspace = true
tranquil-pds = { workspace = true }
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
tranquil-scopes = { workspace = true }
tranquil-signal = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
backon = { workspace = true }
base32 = { workspace = true }
@@ -50,4 +47,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
zip = { workspace = true }
[features]
bsky = ["bsky-support"]
bsky-support = []
-3
View File
@@ -1,3 +0,0 @@
mod preferences;
pub use preferences::{get_preferences, put_preferences};
+14 -9
View File
@@ -12,8 +12,8 @@ use tranquil_pds::api::{
};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::delegation::{
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
verify_can_control_accounts,
DelegationActionType, IdentityResolutionError, SCOPE_PRESETS, ValidatedDelegationScope,
verify_can_add_controllers, verify_can_control_accounts,
};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
@@ -65,16 +65,16 @@ pub async fn add_controller(
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.map_err(|_| ApiError::ControllerNotFound)?;
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
if !resolved.is_local
&& let Some(ref pds_url) = resolved.pds_url
{
if !pds_url.starts_with("https://") {
return Err(ApiError::InvalidDelegation(
"Controller PDS must use HTTPS".into(),
));
}
match state
.cross_pds_oauth
.check_remote_is_delegated(pds_url, &input.controller_did)
@@ -477,7 +477,12 @@ pub async fn resolve_controller(
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.map_err(|_| ApiError::ControllerNotFound)?;
.map_err(|e| match e {
IdentityResolutionError::PdsEndpoint(_) => ApiError::InvalidDelegation(
"Controller PDS endpoint isn't a usable https URL".into(),
),
IdentityResolutionError::DidResolution(_) => ApiError::ControllerNotFound,
})?;
Ok(Json(resolved))
}
+2 -7
View File
@@ -147,12 +147,7 @@ async fn try_reactivate_migration(
Json(CreateAccountOutput {
handle: handle.clone(),
did: did.clone(),
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
did_doc: state.did_resolver.fetch_did_document(did).await.ok(),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
@@ -568,7 +563,7 @@ pub async fn create_account(
Json(CreateAccountOutput {
handle: handle.clone(),
did,
did_doc: did_doc.map(|f| (*f).clone()),
did_doc,
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
+24 -13
View File
@@ -164,6 +164,13 @@ pub async fn resolve_signing_key(
}
}
#[cfg_attr(
not(feature = "bsky"),
expect(
unused_variables,
reason = "only the bsky block writes display_name into the default profile record"
)
)]
pub async fn sequence_new_account(
state: &AppState,
did: &Did,
@@ -205,20 +212,24 @@ pub async fn sequence_new_account(
{
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
}
+34 -13
View File
@@ -1,5 +1,12 @@
pub mod actor;
// BSKY: Bluesky requires PDSs to implement its private preferences API
#[cfg(feature = "bsky-support")]
pub mod actor {
mod preferences;
pub use preferences::{get_preferences, put_preferences};
}
pub mod admin;
#[cfg(feature = "bsky")]
pub mod age_assurance;
pub mod common;
pub mod delegation;
@@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router<AppState> {
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
axum::Router::new()
let router = axum::Router::new()
.route("/_health", get(server::health))
.route(
"/com.atproto.server.describeServer",
@@ -373,14 +380,6 @@ pub fn api_routes() -> axum::Router<AppState> {
post(admin::update_subject_status),
)
.route("/com.atproto.admin.sendEmail", post(admin::send_email))
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
)
.route(
"/com.atproto.temp.checkSignupQueue",
get(temp::check_signup_queue),
@@ -438,7 +437,21 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/_delegation.resolveController",
get(delegation::resolve_controller),
);
#[cfg(feature = "bsky-support")]
let router = router
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
);
#[cfg(feature = "bsky")]
let router = router
.route(
"/app.bsky.ageassurance.getState",
get(age_assurance::get_state),
@@ -446,7 +459,9 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/app.bsky.unspecced.getAgeAssuranceState",
get(age_assurance::get_age_assurance_state),
)
);
router
}
pub fn well_known_api_routes() -> axum::Router<AppState> {
@@ -474,9 +489,15 @@ pub fn webhook_routes() -> axum::Router<AppState> {
pub fn misc_routes() -> axum::Router<AppState> {
use axum::routing::get;
axum::Router::new()
let router = axum::Router::new()
.route("/health", get(server::health))
.route("/robots.txt", get(server::robots_txt))
.route("/favicon.ico", get(server::get_logo))
.route("/u/{handle}/did.json", get(identity::user_did_doc))
.route("/u/{handle}/did.json", get(identity::user_did_doc));
if tranquil_config::get().server.rfc_moo_compliance {
router.route("/cow.txt", get(server::cow_txt))
} else {
router
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::proxy_client::{is_ssrf_safe, proxy_client};
use tranquil_pds::auth::{AnyUser, Auth};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Nsid};
use tranquil_pds::types::{Did, DidRef, Nsid};
static CREATE_REPORT_NSID: LazyLock<Nsid> =
LazyLock::new(|| "com.atproto.moderation.createReport".parse().unwrap());
@@ -151,8 +151,9 @@ async fn proxy_to_report_service(
let service_token = match tranquil_pds::auth::create_service_token(
&auth_user.did,
service_did,
&DidRef::from(service_did),
Some(&CREATE_REPORT_NSID),
None,
&key_bytes,
) {
Ok(t) => t,
@@ -1 +0,0 @@
pub use tranquil_pds::repo_ops::*;
@@ -10,7 +10,7 @@ use serde_json::Value;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
use tranquil_pds::api::EmptyResponse;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
@@ -212,9 +212,10 @@ async fn assert_valid_did_document_for_service(
if let Some(ref expected_rotation_key) = server_rotation_key
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
{
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
debug!(
"DID {} rotation keys {:?} omit the PDS-managed server rotation key {}",
did, doc_rotation_keys, expected_rotation_key
);
}
let doc_signing_key = doc_data
@@ -251,13 +252,10 @@ async fn assert_valid_did_document_for_service(
}
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
warn!(
debug!(
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
did, doc_rotation_keys, expected_did_key
);
return Err(ApiError::InvalidRequest(
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
));
}
}
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
+57
View File
@@ -0,0 +1,57 @@
..........................
....*o|||||||8#@@@@@@@@@@@@@@@@@@@@@@@###&|o:_..
..*:o|||&8##@###8888888######@#@###########################|*...
.:o|||8#####8888|:::**. *&########################@@################&o_
.*o&8###@#8&o*_. :###@##############@########################@@##&o_
.*o8########& :##@#@##############@############################@###|_
.*o|8##########8o .#######################################################&o_
*&##|_ ..*&##8&o*|88888|_ _#######################################@##################|.
*#####& *&######&o_..*o|o:_ .&##o _###########################################################&_
_##8*##8 .|88|:::|#######8###8|*:_ .&#@@8 _##@@@########################################################&_
_#@8_##8_ *8#8|*_ _:|#####&&####8 .&##############################################################|
_#@8.|##8_ _::o###8&##8 .|##@############################8###########################@@#|_
*###o.|88o ..*&####|..##& _|##########################8|_ .|#############################8
*|###|_ ._&####8|*_ _*_ _::&8888888888888888|::*_ .|##@####@@@##################|
*&###|_ _:_ .&88###8|*_ ..... .|#####@@@##################8
.##@#& _##& .|##o _#@@#@#| .|#######&:_ _|###@####################8
.:8##8o _o:*&##| *##8_.&@@##@#| _::o8#8|::|#####|_ _|#################88###8
.&##&*_ *###o_###| .|##8*&##|*###o _###8####8|_ _:|###|_ .*o|||o:_ _:::&8888888888|_ _##8
.###|_. _###o *###|*&#######8 *##8 .##8_ _:|###|_ _|###|_ .&########o _##&
_|####8|&##8:_ _|#########88o .##8 *##& _|###o .|###o .#########| .o##o
o#8|*:#@@###o _:::*__*_ _##8_ _##8_ _&##|_ *##8_ *8#####8|_ .*oo:_ o##|
*###o.&#####& _oo* .8##& .8##8_ .|##& o##& _::::_.*o|8######|_ .##8.
_###&o&##8_:*_ .###& .###|_&#####| _##8 :###o *ooo&#########@#@#& ....:##&
.|8||###&. _**_ .###88##|*&###|*._&##& *|##8_ *o&####@@####@@######& .*o||||||&#######8_
*&###o _|88##8_ _:8######|*:###|_ _##################88|_ *&#################&
*#####o *&8o *##& _:::*_.&##|_ _#@##############8_ :##################8*
.###&##8_.|88o *&8o _@@& .###| .&####@#########|_ .####@###@@########8*
_##&.|###|_.... .|88o _##8* *&###|_ *###o _:&88######8|_ .*o|||##################o
_##8_ _|########|_ .*o8####&#@@#@##o *#@8 _*:*. .&###@###################|
.|##& _:::::&##& .*&##############@#8_ .###o _#@####################|_
.&##|_ .&##8_ *o&####################8_ *##& .&#####@@############8*
.|##8_.&###&####8_ _########################8**##& _##################|_
.&###&##888888##8_ .|88######@###########|*######o _|8###############|_
.&#####o *###|_ _::::::*o##8**o##8 .|###8o .&##@#############&*
.|####o _|###|_ _##8.*&##& _*_ .#################|
_*_ _|###|_.. .|#####8|_ *&#@#########8###&
*#######&|o:_... ..*:::*. ......._:o&8####888&o:#####8_
_###|&888#####@#####&|||o:_........................._:o||||8##@@@@####8|:*_ _:::*_
.|#@###o _:::o#@#888######@@@@@@@@@@@@@@@@@@@@@@@@#####888|::::::**_
_::*_ :##& *&8|_:::::::::::::::::::::::::**_
.###8||&##8o
_|888888|_
+4
View File
@@ -29,6 +29,10 @@ pub async fn robots_txt() -> impl IntoResponse {
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
)
}
pub async fn cow_txt() -> &'static str {
include_str!("cow.txt")
}
pub fn is_self_hosted_did_web_enabled() -> bool {
tranquil_config::get().server.enable_pds_hosted_did_web
}
+1 -1
View File
@@ -28,7 +28,7 @@ pub use email::{
};
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};
pub use meta::{cow_txt, describe_server, health, robots_txt};
pub use migration::{get_did_document, update_did_document};
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,
+15 -10
View File
@@ -11,7 +11,7 @@ use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::extractor::{Auth, Permissive};
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
use tranquil_pds::types::DidRef;
use tranquil_types::Nsid;
static CREATE_ACCOUNT_NSID: LazyLock<Nsid> =
@@ -45,7 +45,7 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
#[derive(Deserialize)]
pub struct GetServiceAuthParams {
pub aud: Did,
pub aud: DidRef,
pub lxm: Option<Nsid>,
pub exp: Option<i64>,
}
@@ -169,14 +169,19 @@ pub async fn get_service_auth(
}
}
let service_token =
match tranquil_pds::auth::create_service_token(&auth.did, &params.aud, lxm, &key_bytes) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let service_token = match tranquil_pds::auth::create_service_token(
&auth.did,
&params.aud,
lxm,
params.exp,
&key_bytes,
) {
Ok(t) => t,
Err(e) => {
error!("Failed to create service token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
(
StatusCode::OK,
Json(GetServiceAuthOutput {
+3 -3
View File
@@ -351,7 +351,7 @@ pub async fn create_session(
refresh_jwt: refresh_meta.token,
handle,
did: row.did,
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
email: row.email,
email_confirmed: Some(row.channel_verification.email),
email_auth_factor: email_auth_factor_out,
@@ -444,7 +444,7 @@ pub async fn get_session(
status: account_state.status_for_session().map(String::from),
migrated_to_pds,
migrated_at,
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
}))
}
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
@@ -800,7 +800,7 @@ async fn build_refresh_session_output(
preferred_locale: u.preferred_locale,
is_admin: u.is_admin,
active: account_state.is_active(),
did_doc: did_doc.ok().map(|f| (*f).clone()),
did_doc: did_doc.ok(),
status: account_state.status_for_session().map(String::from),
}))
}
-31
View File
@@ -1,31 +0,0 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use tranquil_pds::api::SuccessResponse;
use tranquil_pds::state::AppState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmChannelVerificationInput {
pub channel: tranquil_db_traits::CommsChannel,
pub identifier: String,
pub code: String,
}
pub async fn confirm_channel_verification(
State(state): State<AppState>,
Json(input): Json<ConfirmChannelVerificationInput>,
) -> Response {
let token_input = crate::server::VerifyTokenInput {
token: input.code,
identifier: input.identifier,
};
match crate::server::verify_token_internal(&state, token_input).await {
Ok(_output) => SuccessResponse::ok().into_response(),
Err(e) => e.into_response(),
}
}
+1
View File
@@ -24,3 +24,4 @@ subtle = { workspace = true }
totp-rs = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
brotli = "8.0.4"
+183
View File
@@ -0,0 +1,183 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use brotli::{CompressorWriter, Decompressor};
use std::fmt;
use std::io::{Read, Write};
const COMPRESSED_PREFIX: &str = "$br$";
const QUALITY: u32 = 9;
const WINDOW_BITS: u32 = 16;
const BUFFER_SIZE: usize = 4096;
const MAX_SCOPE_LEN: u64 = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeDecodeError {
Base64DecodeFailed,
DecompressFailed,
TooLarge,
}
impl fmt::Display for ScopeDecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Base64DecodeFailed => write!(f, "Base64 decode of compressed scope failed"),
Self::DecompressFailed => write!(f, "Brotli decompression of scope failed"),
Self::TooLarge => write!(f, "Decompressed scope exceeds maximum length"),
}
}
}
impl std::error::Error for ScopeDecodeError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeEncodeError {
TooLarge,
}
impl fmt::Display for ScopeEncodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TooLarge => write!(f, "Scope exceeds maximum length"),
}
}
}
impl std::error::Error for ScopeEncodeError {}
fn brotli_compress(input: &str) -> Vec<u8> {
let mut writer = CompressorWriter::new(Vec::new(), BUFFER_SIZE, QUALITY, WINDOW_BITS);
writer
.write_all(input.as_bytes())
.expect("writing to a Vec cannot fail");
writer.into_inner()
}
fn brotli_decompress(input: &[u8]) -> Result<String, ScopeDecodeError> {
let mut output = String::new();
Decompressor::new(input, BUFFER_SIZE)
.take(MAX_SCOPE_LEN + 1)
.read_to_string(&mut output)
.map_err(|_| ScopeDecodeError::DecompressFailed)?;
if output.len() as u64 > MAX_SCOPE_LEN {
return Err(ScopeDecodeError::TooLarge);
}
Ok(output)
}
pub fn encode_scope(scope: &str) -> Result<String, ScopeEncodeError> {
if scope.len() as u64 > MAX_SCOPE_LEN {
return Err(ScopeEncodeError::TooLarge);
}
let tagged = format!(
"{COMPRESSED_PREFIX}{}",
URL_SAFE_NO_PAD.encode(brotli_compress(scope))
);
if tagged.len() < scope.len() || scope.starts_with(COMPRESSED_PREFIX) {
Ok(tagged)
} else {
Ok(scope.to_owned())
}
}
pub fn decode_scope(scope: &str) -> Result<String, ScopeDecodeError> {
let Some(encoded) = scope.strip_prefix(COMPRESSED_PREFIX) else {
return Ok(scope.to_owned());
};
let compressed = URL_SAFE_NO_PAD
.decode(encoded)
.map_err(|_| ScopeDecodeError::Base64DecodeFailed)?;
brotli_decompress(&compressed)
}
#[cfg(test)]
mod tests {
use super::*;
fn long_scope() -> String {
let mut scope = String::from("transition:generic transition:chat.bsky");
for collection in [
"social.colibri.message",
"social.colibri.community",
"social.colibri.reaction",
"social.colibri.member",
"social.colibri.channel.read",
] {
scope.push_str(&format!(" repo:{collection}?action=create&action=delete"));
}
scope
}
#[test]
fn long_scope_roundtrips_through_compression() {
let scope = long_scope();
let encoded = encode_scope(&scope).unwrap();
assert!(encoded.starts_with(COMPRESSED_PREFIX));
assert!(encoded.len() < scope.len());
assert_eq!(decode_scope(&encoded).unwrap(), scope);
}
#[test]
fn short_scope_stays_plaintext() {
let encoded = encode_scope("com.atproto.access").unwrap();
assert_eq!(encoded, "com.atproto.access");
assert_eq!(decode_scope(&encoded).unwrap(), "com.atproto.access");
}
#[test]
fn untagged_scope_passes_through() {
assert_eq!(
decode_scope("com.atproto.refresh").unwrap(),
"com.atproto.refresh"
);
assert_eq!(decode_scope("").unwrap(), "");
}
#[test]
fn malformed_compressed_scope_errors_instead_of_panicking() {
assert_eq!(
decode_scope("$br$not valid base64!"),
Err(ScopeDecodeError::Base64DecodeFailed)
);
assert_eq!(
decode_scope("$br$AAAAAAAAAAAAAAAA"),
Err(ScopeDecodeError::DecompressFailed)
);
}
#[test]
fn compression_bomb_is_rejected() {
let bomb = URL_SAFE_NO_PAD.encode(brotli_compress(&"a".repeat(MAX_SCOPE_LEN as usize * 2)));
assert_eq!(
decode_scope(&format!("{COMPRESSED_PREFIX}{bomb}")),
Err(ScopeDecodeError::TooLarge)
);
}
#[test]
fn plaintext_that_looks_compressed_roundtrips() {
let scope = "$br$repo:*";
let encoded = encode_scope(scope).unwrap();
assert!(encoded.starts_with(COMPRESSED_PREFIX));
assert_eq!(decode_scope(&encoded).unwrap(), scope);
}
#[test]
fn encode_rejects_oversized_scope() {
let oversized = "a".repeat(MAX_SCOPE_LEN as usize + 1);
assert_eq!(encode_scope(&oversized), Err(ScopeEncodeError::TooLarge));
assert!(encode_scope(&"a".repeat(MAX_SCOPE_LEN as usize)).is_ok());
}
}
+3
View File
@@ -1,3 +1,4 @@
mod compress;
mod token;
mod totp;
mod types;
@@ -12,6 +13,8 @@ pub use token::{
create_service_token_hs256,
};
pub use compress::{ScopeDecodeError, ScopeEncodeError, decode_scope, encode_scope};
pub use totp::{
TotpError, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
+15 -9
View File
@@ -1,14 +1,16 @@
use crate::compress::encode_scope;
use super::types::{
ActClaim, Claims, Header, SigningAlgorithm, TokenScope, TokenType, TokenWithMetadata,
};
use anyhow::Result;
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
use tranquil_types::{Did, Jti, Nsid};
use tranquil_types::{Did, DidRef, Jti, Nsid};
type HmacSha256 = Hmac<Sha256>;
@@ -125,16 +127,20 @@ pub fn create_refresh_token_with_jti(
pub fn create_service_token(
did: &Did,
aud: &Did,
aud: &DidRef,
lxm: Option<&Nsid>,
exp: Option<i64>,
key_bytes: &[u8],
) -> Result<String> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp();
let expiration = match exp {
Some(exp) => exp,
None => Utc::now()
.checked_add_signed(Duration::seconds(60))
.expect("valid timestamp")
.timestamp(),
};
let claims = Claims {
iss: did.clone(),
@@ -205,7 +211,7 @@ fn create_signed_token_pinned(
aud: format!("did:web:{}", aud_hostname),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
lxm: None,
jti: jti.clone(),
act,
@@ -328,7 +334,7 @@ fn create_hs256_token_with_metadata(
),
exp: expiration,
iat: Utc::now().timestamp(),
scope: Some(scope.to_string()),
scope: Some(encode_scope(scope).context("Scope too large to encode")?),
lxm: None,
jti: jti.clone(),
act: None,
+14 -2
View File
@@ -1,3 +1,5 @@
use crate::compress::decode_scope;
use super::types::{
Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, UnsafeClaims,
@@ -164,9 +166,15 @@ pub fn verify_token_es256k(
.decode(claims_b64)
.map_err(|_| TokenVerifyError::Invalid("Base64 decode of claims failed"))?;
let claims: Claims = serde_json::from_slice(&claims_bytes)
let mut claims: Claims = serde_json::from_slice(&claims_bytes)
.map_err(|_| TokenVerifyError::Invalid("JSON decode of claims failed"))?;
if let Some(scope) = &claims.scope {
claims.scope = Some(
decode_scope(scope).map_err(|_| TokenVerifyError::Invalid("Invalid token scope"))?,
);
}
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(TokenVerifyError::Expired);
@@ -244,9 +252,13 @@ fn verify_token_hs256_internal(
.decode(claims_b64)
.context("Base64 decode of claims failed")?;
let claims: Claims =
let mut claims: Claims =
serde_json::from_slice(&claims_bytes).context("JSON decode of claims failed")?;
if let Some(scope) = &claims.scope {
claims.scope = Some(decode_scope(scope).context("Invalid scope claim encoding")?);
}
let now = Utc::now().timestamp();
if claims.exp < now {
return Err(anyhow!("Token expired"));
+1 -2
View File
@@ -5,12 +5,11 @@ edition.workspace = true
license.workspace = true
[features]
default = []
valkey = ["dep:redis"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-infra = { workspace = true }
tranquil-infra = { workspace = true, features = ["cache-keys"] }
tranquil-ripple = { workspace = true }
async-trait = { workspace = true }
+4 -3
View File
@@ -1,4 +1,6 @@
pub use tranquil_infra::{Cache, CacheError, DistributedRateLimiter};
pub use tranquil_infra::{
Cache, CacheError, DistributedRateLimiter, cache_keys, cached_json, read_json, write_json,
};
use async_trait::async_trait;
use std::sync::Arc;
@@ -173,11 +175,10 @@ pub async fn create_cache(
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
#[cfg(feature = "valkey")]
if backend == "valkey" {
if let Some(url) = valkey_url {
if let Some(url) = cache_cfg.and_then(|c| c.valkey_url.as_deref()) {
match ValkeyCache::new(url).await {
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
-1
View File
@@ -19,7 +19,6 @@ reqwest = { workspace = true }
rsa = { workspace = true }
secrecy = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
+123 -8
View File
@@ -1,25 +1,41 @@
use lettre::Message;
use lettre::message::Mailbox;
use lettre::message::header::ContentType;
use lettre::message::header::{Header, HeaderName, HeaderValue};
use uuid::Uuid;
use super::types::EmailDomain;
use crate::sender::SendError;
use crate::types::QueuedComms;
use crate::types::{CommsType, QueuedComms};
pub(super) fn build(from: &Mailbox, qc: &QueuedComms) -> Result<Message, SendError> {
pub(super) fn build(
from: &Mailbox,
qc: &QueuedComms,
apply_atmos_categories: bool,
) -> Result<Message, SendError> {
let to: Mailbox = qc
.recipient
.parse()
.map_err(|e: lettre::address::AddressError| SendError::InvalidRecipient(e.to_string()))?;
let subject = qc.subject.as_deref().unwrap_or("Notification");
let message_id = format!("<{}@{}>", Uuid::new_v4(), from.email.domain());
Message::builder()
let builder = Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.message_id(Some(message_id))
.header(ContentType::TEXT_PLAIN)
.header(ContentType::TEXT_PLAIN);
let category = apply_atmos_categories
.then(|| atmos_category(qc.comms_type))
.flatten();
let builder = match category {
Some(category) => builder.header(category),
None => builder,
};
builder
.body(qc.body.clone())
.map_err(|e| SendError::MessageBuild(e.to_string()))
}
@@ -34,10 +50,57 @@ pub(super) fn recipient_domain(message: &Message) -> Result<EmailDomain, SendErr
.map_err(|e| SendError::InvalidRecipient(format!("invalid recipient domain: {e}")))
}
// for use with comail.at
#[derive(Debug, Clone, PartialEq, Eq, Copy)]
enum AtmosCategory {
PasswordReset,
MfaOtp,
Verification,
}
impl AtmosCategory {
fn as_str(self) -> &'static str {
match self {
Self::PasswordReset => "password-reset",
Self::MfaOtp => "mfa-otp",
Self::Verification => "verification",
}
}
}
impl Header for AtmosCategory {
fn name() -> HeaderName {
HeaderName::new_from_ascii_str("X-Atmos-Category")
}
fn parse(_s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
//since we're never receiving email, we don't care about parsing
Err("X-Atmos-Category is write-only".into())
}
fn display(&self) -> HeaderValue {
HeaderValue::new(Self::name(), self.as_str().to_string())
}
}
fn atmos_category(comms_type: CommsType) -> Option<AtmosCategory> {
use CommsType::*;
match comms_type {
EmailVerification
| ChannelVerification
| ChannelVerified
| MigrationVerification
| LegacyLoginAlert
| EmailUpdate
| PlcOperation
| AccountDeletion
| Welcome => Some(AtmosCategory::Verification),
PasswordReset | PasskeyRecovery => Some(AtmosCategory::PasswordReset),
TwoFactorCode => Some(AtmosCategory::MfaOtp),
AdminEmail => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommsChannel, CommsStatus, CommsType};
use crate::types::{CommsChannel, CommsStatus};
use chrono::Utc;
use uuid::Uuid;
@@ -71,6 +134,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("Welcome"), "Hello world."),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -87,6 +151,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("héllo wörld"), "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -99,6 +164,7 @@ mod tests {
let result = build(
&from_mailbox(),
&fixture("x@nel.pet\r\nBcc: evil@x", Some("s"), "b"),
false,
);
assert!(matches!(result, Err(SendError::InvalidRecipient(_))));
}
@@ -108,6 +174,7 @@ mod tests {
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("hi\r\nBcc: evil@nel.pet"), "body"),
false,
)
.expect("subject CRLF should be encoded, not rejected");
let raw = String::from_utf8(msg.formatted()).unwrap();
@@ -123,7 +190,12 @@ mod tests {
#[test]
fn message_id_uses_from_domain() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", Some("s"), "b"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
let line = raw
.lines()
@@ -137,15 +209,58 @@ mod tests {
#[test]
fn missing_subject_uses_default() {
let msg = build(&from_mailbox(), &fixture("user@nel.pet", None, "Body")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@nel.pet", None, "Body"),
false,
)
.unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("Subject: Notification"));
}
#[test]
fn recipient_domain_extracted() {
let msg = build(&from_mailbox(), &fixture("user@Nel.PET", Some("s"), "b")).unwrap();
let msg = build(
&from_mailbox(),
&fixture("user@Nel.PET", Some("s"), "b"),
false,
)
.unwrap();
let d = recipient_domain(&msg).unwrap();
assert_eq!(d.as_str(), "nel.pet");
}
#[test]
fn atmos_category_header_present_when_enabled_and_mapped() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(raw.contains("X-Atmos-Category: password-reset"));
}
#[test]
fn atmos_category_header_absent_when_disabled() {
let qc = QueuedComms {
comms_type: CommsType::PasswordReset,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, false).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
#[test]
fn atmos_category_header_absent_when_unmapped() {
let qc = QueuedComms {
comms_type: CommsType::AdminEmail,
..fixture("user@nel.pet", Some("s"), "b")
};
let msg = build(&from_mailbox(), &qc, true).unwrap();
let raw = String::from_utf8(msg.formatted()).unwrap();
assert!(!raw.contains("X-Atmos-Category"));
}
}
+55 -1
View File
@@ -124,6 +124,7 @@ fn build_smarthost(
Ok(SendMode::Smarthost {
transport: Box::new(builder.build()),
total_timeout,
apply_atmos_categories: cfg.email.smarthost.apply_atmos_categories,
})
}
@@ -176,6 +177,16 @@ fn build_dkim(cfg: &tranquil_config::DkimConfig) -> Result<Option<DkimSigner>, S
DkimSigner::load(selector, domain, path).map(Some)
}
fn wants_atmos_categories(mode: &SendMode) -> bool {
match mode {
SendMode::Smarthost {
apply_atmos_categories,
..
} => *apply_atmos_categories,
SendMode::DirectMx { .. } => false,
}
}
#[async_trait]
impl CommsSender for EmailSender {
fn channel(&self) -> CommsChannel {
@@ -183,7 +194,8 @@ impl CommsSender for EmailSender {
}
async fn send(&self, notification: &QueuedComms) -> Result<(), SendError> {
let mut message = message::build(&self.from, notification)?;
let mut message =
message::build(&self.from, notification, wants_atmos_categories(&self.mode))?;
if let Some(signer) = &self.dkim {
signer.sign(&mut message);
}
@@ -196,3 +208,45 @@ impl CommsSender for EmailSender {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use lettre::Tokio1Executor;
use std::time::Duration;
fn dummy_smarthost(apply_atmos_categories: bool) -> SendMode {
let transport =
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("localhost").build();
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout: Duration::from_secs(10),
apply_atmos_categories,
}
}
fn dummy_direct_mx() -> SendMode {
SendMode::DirectMx {
resolver: Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
ResolverOpts::default(),
)),
helo: HeloName::parse("mta.nel.pet").unwrap(),
command_timeout: Duration::from_secs(5),
total_timeout: Duration::from_secs(10),
require_tls: false,
inflight: Arc::new(Semaphore::new(1)),
}
}
#[tokio::test]
async fn smarthost_reflects_its_own_flag() {
assert!(wants_atmos_categories(&dummy_smarthost(true)));
assert!(!wants_atmos_categories(&dummy_smarthost(false)));
}
#[test]
fn direct_mx_never_wants_atmos_categories() {
assert!(!wants_atmos_categories(&dummy_direct_mx()));
}
}
+11 -2
View File
@@ -19,6 +19,7 @@ pub enum SendMode {
Smarthost {
transport: Box<AsyncSmtpTransport<Tokio1Executor>>,
total_timeout: Duration,
apply_atmos_categories: bool,
},
DirectMx {
resolver: Arc<TokioAsyncResolver>,
@@ -33,8 +34,15 @@ pub enum SendMode {
impl std::fmt::Debug for SendMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Smarthost { total_timeout, .. } => {
write!(f, "SendMode::Smarthost(total_timeout={total_timeout:?})")
Self::Smarthost {
total_timeout,
apply_atmos_categories,
..
} => {
write!(
f,
"SendMode::Smarthost(total_timeout={total_timeout:?}, apply_atmos_categories={apply_atmos_categories:?})"
)
}
Self::DirectMx {
helo, require_tls, ..
@@ -52,6 +60,7 @@ pub async fn dispatch(mode: &SendMode, message: Message) -> Result<(), SendError
SendMode::Smarthost {
transport,
total_timeout,
..
} => with_total_timeout(*total_timeout, run_send(transport, message)).await,
SendMode::DirectMx {
resolver,
@@ -53,6 +53,7 @@ fn build_smarthost_sender_with_total_timeout(
SendMode::Smarthost {
transport: Box::new(transport),
total_timeout,
apply_atmos_categories: false,
},
None,
)
-1
View File
@@ -6,4 +6,3 @@ license.workspace = true
[dependencies]
confique = { workspace = true }
serde = { workspace = true }
+10 -1
View File
@@ -445,6 +445,10 @@ pub struct ServerConfig {
#[config(env = "ENABLE_PDS_HOSTED_DID_WEB", default = false)]
pub enable_pds_hosted_did_web: bool,
/// iykyk!
#[config(env = "RFC_MOO_COMPLIANCE", default = false)]
pub rfc_moo_compliance: bool,
/// When set to true, skip age-assurance birthday prompt for all accounts.
#[config(env = "PDS_AGE_ASSURANCE_OVERRIDE", default = false)]
pub age_assurance_override: bool,
@@ -831,7 +835,7 @@ pub struct PlcConfig {
#[config(env = "PLC_CONNECT_TIMEOUT_SECS", default = 5)]
pub connect_timeout_secs: u64,
/// Seconds to cache DID documents in memory.
/// Seconds to cache DID documents.
#[config(env = "DID_CACHE_TTL_SECS", default = 300)]
pub did_cache_ttl_secs: u64,
}
@@ -1120,6 +1124,10 @@ pub struct SmarthostConfig {
/// stuck relay cannot stall the comms queue.
#[config(env = "MAIL_SMARTHOST_TOTAL_TIMEOUT_SECS", default = 60)]
pub total_timeout_secs: u64,
/// Apply Atmos/Comail.at categories for headers to be categorized appropriately.
#[config(env = "MAIL_APPLY_ATMOS_CATEGORIES", default = false)]
pub apply_atmos_categories: bool,
}
#[derive(Debug, Config)]
@@ -1981,6 +1989,7 @@ port = 587
pool_size: 4,
command_timeout_secs: 30,
total_timeout_secs: 60,
apply_atmos_categories: false,
},
direct_mx: DirectMxConfig {
command_timeout_secs: 30,
-2
View File
@@ -5,9 +5,7 @@ edition.workspace = true
license.workspace = true
[features]
default = ["postgres"]
postgres = []
sqlite = []
[dependencies]
tranquil-db-traits = { workspace = true }
+7 -1
View File
@@ -4,10 +4,16 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
testing = []
cache-keys = ["dep:tranquil-types"]
[dependencies]
tranquil-config = { workspace = true }
tranquil-types = { workspace = true, optional = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+103
View File
@@ -0,0 +1,103 @@
use tranquil_types::{
CidLink, ClientId, CrossPdsState, Did, EmailTokenPurpose, Handle, Jti, JwksUri, Nsid, PdsUrl,
SsoIssuer, SsoJwksUri,
};
pub fn session_key(did: &Did, jti: &Jti) -> String {
format!("auth:session:{}:{}", did, jti)
}
pub fn signing_key_key(did: &Did) -> String {
format!("auth:key:{}", did)
}
pub fn user_status_key(did: &Did) -> String {
format!("auth:status:{}", did)
}
pub fn handle_key(handle: &Handle) -> String {
format!("handle:{}", handle)
}
pub fn reauth_key(did: &Did) -> String {
format!("reauth:{}", did)
}
pub fn plc_doc_key(did: &Did) -> String {
format!("plc:doc:{}", did)
}
pub fn plc_data_key(did: &Did) -> String {
format!("plc:data:{}", did)
}
pub fn did_web_doc_key(did: &Did) -> String {
format!("did:web:doc:{}", did)
}
pub fn email_update_key(did: &Did) -> String {
format!("email_update:{}", did)
}
pub fn email_token_key(did: &Did, purpose: EmailTokenPurpose) -> String {
format!("email_token:{}:{}", purpose, did)
}
pub fn legacy_2fa_challenge_key(did: &Did) -> String {
format!("legacy_2fa:{}", did)
}
pub fn legacy_2fa_cooldown_key(did: &Did) -> String {
format!("legacy_2fa_cooldown:{}", did)
}
pub fn scope_ref_key(cid: &CidLink) -> String {
format!("scope_ref:{}", cid)
}
pub fn auto_verify_sent_key(did: &Did) -> String {
format!("auto_verify_sent:{}", did)
}
pub fn permission_set_key(nsid: &Nsid, aud: Option<&str>) -> String {
match aud {
Some(a) => format!("permset:{}:{}", nsid, a),
None => format!("permset:{}", nsid),
}
}
pub fn oauth_client_meta_key(client_id: &ClientId) -> String {
format!("oauth:client_meta:{}", client_id)
}
pub fn oauth_client_jwks_key(jwks_uri: &JwksUri) -> String {
format!("oauth:jwks:{}", jwks_uri.canonical())
}
pub fn oauth_client_jwks_cooldown_key(jwks_uri: &JwksUri) -> String {
format!("oauth:jwks_cooldown:{}", jwks_uri.canonical())
}
pub fn sso_jwks_key(jwks_uri: &SsoJwksUri) -> String {
format!("sso:jwks:{}", jwks_uri.canonical())
}
pub fn oidc_discovery_key(issuer: &SsoIssuer) -> String {
format!("oidc:discovery:{}", issuer.canonical())
}
pub fn cross_pds_state_key(state: &CrossPdsState) -> String {
format!("cross_pds_state:{}", state)
}
pub fn cross_pds_oauth_meta_key(pds_url: &PdsUrl) -> String {
format!("cross_pds_oauth_meta:v2:{}", pds_url.canonical())
}
pub fn lexicon_doc_key(nsid: &Nsid) -> String {
format!("lexicon:doc:{}", nsid)
}
pub fn lexicon_negative_key(nsid: &Nsid) -> String {
format!("lexicon:neg:{}", nsid)
}
+45
View File
@@ -1,6 +1,15 @@
#[cfg(feature = "cache-keys")]
pub mod cache_keys;
#[cfg(feature = "testing")]
mod memory_cache;
#[cfg(feature = "testing")]
pub use memory_cache::MemoryCache;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
@@ -57,6 +66,42 @@ pub trait Cache: Send + Sync {
}
}
pub async fn read_json<T: serde::de::DeserializeOwned>(cache: &dyn Cache, key: &str) -> Option<T> {
let json = cache.get(key).await?;
serde_json::from_str(&json).ok()
}
pub async fn write_json<T: serde::Serialize>(
cache: &dyn Cache,
key: &str,
value: &T,
ttl: Duration,
) {
if let Ok(json) = serde_json::to_string(value) {
let _ = cache.set(key, &json, ttl).await;
}
}
pub async fn cached_json<T, E, Fut>(
cache: &dyn Cache,
key: &str,
ttl: Duration,
fetch: impl FnOnce() -> Fut,
) -> Result<T, E>
where
T: serde::Serialize + serde::de::DeserializeOwned,
Fut: Future<Output = Result<T, E>>,
{
match read_json(cache, key).await {
Some(value) => Ok(value),
None => {
let value = fetch().await?;
write_json(cache, key, &value, ttl).await;
Ok(value)
}
}
}
#[async_trait]
pub trait DistributedRateLimiter: Send + Sync {
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool;
+74
View File
@@ -0,0 +1,74 @@
use crate::{Cache, CacheError};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct Entry {
value: Vec<u8>,
expires_at: Instant,
}
#[derive(Default)]
pub struct MemoryCache {
entries: Mutex<HashMap<String, Entry>>,
}
impl MemoryCache {
pub fn new() -> Self {
Self::default()
}
fn read(&self, key: &str) -> Option<Vec<u8>> {
let now = Instant::now();
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
match entries.get(key) {
Some(entry) if entry.expires_at > now => Some(entry.value.clone()),
Some(_) => {
entries.remove(key);
None
}
None => None,
}
}
fn write(&self, key: &str, value: Vec<u8>, ttl: Duration) {
let entry = Entry {
value,
expires_at: Instant::now() + ttl,
};
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key.to_string(), entry);
}
}
#[async_trait]
impl Cache for MemoryCache {
async fn get(&self, key: &str) -> Option<String> {
self.read(key).and_then(|v| String::from_utf8(v).ok())
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
self.write(key, value.as_bytes().to_vec(), ttl);
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(key);
Ok(())
}
async fn get_bytes(&self, key: &str) -> Option<Vec<u8>> {
self.read(key)
}
async fn set_bytes(&self, key: &str, value: &[u8], ttl: Duration) -> Result<(), CacheError> {
self.write(key, value.to_vec(), ttl);
Ok(())
}
}
+4 -4
View File
@@ -5,11 +5,11 @@ edition.workspace = true
license.workspace = true
[features]
default = []
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"]
resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:tranquil-infra"]
[dependencies]
tranquil-types = { path = "../tranquil-types", default-features = false }
tranquil-types = { workspace = true }
tranquil-infra = { workspace = true, optional = true, features = ["cache-keys"] }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -20,9 +20,9 @@ hickory-resolver = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
parking_lot = { workspace = true, optional = true }
tracing = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
[dev-dependencies]
wiremock = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
tranquil-infra = { workspace = true, features = ["testing", "cache-keys"] }
+217 -31
View File
@@ -6,9 +6,11 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
use tranquil_infra::cache_keys::{lexicon_doc_key, lexicon_negative_key};
use tranquil_infra::{Cache, read_json, write_json};
use tranquil_types::Nsid;
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
const POSITIVE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const REFRESH_FAILURE_BACKOFF: Duration = Duration::from_secs(60);
const MAX_DYNAMIC_SCHEMAS: usize = 1024;
@@ -17,6 +19,13 @@ struct NegativeEntry {
expires_at: Instant,
}
fn negative_ttl_for(error: &ResolveError) -> Duration {
match error.is_definitive() {
true => NEGATIVE_CACHE_TTL,
false => REFRESH_FAILURE_BACKOFF,
}
}
struct PositiveEntry {
doc: Arc<LexiconDoc>,
expires_at: Instant,
@@ -44,6 +53,7 @@ pub struct DynamicRegistry {
negative_cache: RwLock<HashMap<Nsid, NegativeEntry>>,
in_flight: RwLock<HashMap<Nsid, Arc<Notify>>>,
network_disabled: AtomicBool,
shared: RwLock<Option<Arc<dyn Cache>>>,
}
struct InFlightGuard<'a> {
@@ -70,9 +80,18 @@ impl DynamicRegistry {
negative_cache: RwLock::new(HashMap::new()),
in_flight: RwLock::new(HashMap::new()),
network_disabled: AtomicBool::new(false),
shared: RwLock::new(None),
}
}
pub fn set_shared_cache(&self, cache: Arc<dyn Cache>) {
*self.shared.write() = Some(cache);
}
fn shared_cache(&self) -> Option<Arc<dyn Cache>> {
self.shared.read().clone()
}
pub fn from_env() -> Self {
let registry = Self::new();
let disabled =
@@ -105,13 +124,17 @@ impl DynamicRegistry {
}
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
let cache = self.negative_cache.read();
cache
.get(nsid)
.is_some_and(|entry| entry.expires_at > Instant::now())
self.negative_remaining(nsid).is_some()
}
fn insert_negative(&self, nsid: &Nsid) {
fn negative_remaining(&self, nsid: &Nsid) -> Option<Duration> {
self.negative_cache
.read()
.get(nsid)
.and_then(|entry| entry.expires_at.checked_duration_since(Instant::now()))
}
fn insert_negative(&self, nsid: &Nsid, ttl: Duration) {
let mut cache = self.negative_cache.write();
if cache.len() >= MAX_DYNAMIC_SCHEMAS {
let now = Instant::now();
@@ -120,7 +143,7 @@ impl DynamicRegistry {
cache.insert(
nsid.clone(),
NegativeEntry {
expires_at: Instant::now() + NEGATIVE_CACHE_TTL,
expires_at: Instant::now() + ttl,
},
);
}
@@ -159,6 +182,44 @@ impl DynamicRegistry {
arc
}
async fn shared_get(&self, nsid: &Nsid) -> Option<Arc<LexiconDoc>> {
let cache = self.shared_cache()?;
let doc = read_json::<LexiconDoc>(cache.as_ref(), &lexicon_doc_key(nsid)).await?;
Some(self.insert_schema(doc))
}
async fn shared_put(&self, doc: &LexiconDoc) {
let Some(cache) = self.shared_cache() else {
return;
};
write_json(
cache.as_ref(),
&lexicon_doc_key(&doc.id),
doc,
POSITIVE_CACHE_TTL,
)
.await;
let _ = cache.delete(&lexicon_negative_key(&doc.id)).await;
}
async fn shared_is_negative(&self, nsid: &Nsid) -> bool {
match self.shared_cache() {
Some(cache) => cache.get(&lexicon_negative_key(nsid)).await.is_some(),
None => false,
}
}
async fn shared_put_negative(&self, nsid: &Nsid, error: &ResolveError) {
if !error.is_definitive() {
return;
}
if let Some(cache) = self.shared_cache() {
let _ = cache
.set(&lexicon_negative_key(nsid), "1", NEGATIVE_CACHE_TTL)
.await;
}
}
fn bump_expiry(&self, nsid: &Nsid, duration: Duration) {
let mut store = self.store.write();
if let Some(entry) = store.schemas.get_mut(nsid) {
@@ -203,15 +264,23 @@ impl DynamicRegistry {
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Err(e) => {
let (doc, source) = match self.shared_get(nsid).await {
Some(doc) => (doc, "shared"),
None => (stale, "local"),
};
self.bump_expiry(nsid, REFRESH_FAILURE_BACKOFF);
tracing::warn!(
nsid = %nsid,
error = %e,
"lexicon refresh failed, serving stale cached entry"
source,
"lexicon refresh failed, serving cached entry"
);
Ok(stale)
Ok(doc)
}
},
None => {
@@ -230,34 +299,59 @@ impl DynamicRegistry {
F: FnOnce(Nsid) -> Fut,
Fut: std::future::Future<Output = Result<LexiconDoc, ResolveError>>,
{
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
if let Some(doc) = self.shared_get(nsid).await {
return Ok(doc);
}
if self.is_negative_cached(nsid) {
if let Some(remaining) = self.negative_remaining(nsid) {
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
ttl_secs: remaining.as_secs(),
});
}
if self.shared_is_negative(nsid).await {
// Cache reports 0 remaining TTL for shared negative hit,
// so we mirror for the backoff rather than a full `NEGATIVE_CACHE_TTL`.
self.insert_negative(nsid, REFRESH_FAILURE_BACKOFF);
return Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: REFRESH_FAILURE_BACKOFF.as_secs(),
});
}
if self.network_disabled.load(Ordering::Relaxed) {
return Err(ResolveError::NetworkDisabled);
}
match self.acquire_leadership(nsid) {
Some(_guard) => match resolver(nsid.clone()).await {
Ok(doc) => Ok(self.insert_schema(doc)),
Ok(doc) => {
self.shared_put(&doc).await;
Ok(self.insert_schema(doc))
}
Err(e) => {
self.insert_negative(nsid);
tracing::debug!(nsid = %nsid, error = %e, "caching negative resolution result");
let ttl = negative_ttl_for(&e);
self.insert_negative(nsid, ttl);
self.shared_put_negative(nsid, &e).await;
tracing::debug!(
nsid = %nsid,
error = %e,
ttl_secs = ttl.as_secs(),
"caching negative resolution result"
);
Err(e)
}
},
None => {
self.wait_for_leader(nsid).await;
match self.get_cached(nsid) {
Some(doc) => Ok(doc),
None if self.is_negative_cached(nsid) => Err(ResolveError::NegativelyCached {
match (self.get_cached(nsid), self.negative_remaining(nsid)) {
(Some(doc), _) => Ok(doc),
(None, Some(remaining)) => Err(ResolveError::NegativelyCached {
nsid: nsid.clone(),
ttl_secs: NEGATIVE_CACHE_TTL.as_secs(),
ttl_secs: remaining.as_secs(),
}),
None => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
(None, None) => Err(ResolveError::LeaderAborted { nsid: nsid.clone() }),
}
}
}
@@ -316,6 +410,7 @@ impl Default for DynamicRegistry {
#[cfg(test)]
mod tests {
use super::*;
use tranquil_infra::MemoryCache;
fn nsid(s: &str) -> Nsid {
s.parse().unwrap()
@@ -324,19 +419,19 @@ mod tests {
#[test]
fn test_negative_cache() {
let registry = DynamicRegistry::new();
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
assert!(!registry.is_negative_cached(&nsid("pet.nel.negative")));
registry.insert_negative(&nsid("com.example.test"));
assert!(registry.is_negative_cached(&nsid("com.example.test")));
registry.insert_negative(&nsid("pet.nel.negative"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.negative")));
}
#[tokio::test]
async fn test_negative_cache_returns_appropriate_error_variant() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("com.example.cached"));
registry.insert_negative(&nsid("pet.nel.cached"), NEGATIVE_CACHE_TTL);
let err = registry
.resolve_and_cache(&nsid("com.example.cached"))
.resolve_and_cache(&nsid("pet.nel.cached"))
.await
.unwrap_err();
@@ -383,17 +478,17 @@ mod tests {
fn test_negative_cache_cleared_on_insert() {
let registry = DynamicRegistry::new();
registry.insert_negative(&nsid("com.example.test"));
assert!(registry.is_negative_cached(&nsid("com.example.test")));
registry.insert_negative(&nsid("pet.nel.cleared"), NEGATIVE_CACHE_TTL);
assert!(registry.is_negative_cached(&nsid("pet.nel.cleared")));
let doc = LexiconDoc {
lexicon: 1,
id: nsid("com.example.test"),
id: nsid("pet.nel.cleared"),
defs: HashMap::new(),
};
registry.insert_schema(doc);
assert!(!registry.is_negative_cached(&nsid("com.example.test")));
assert!(!registry.is_negative_cached(&nsid("pet.nel.cleared")));
}
#[test]
@@ -692,4 +787,95 @@ mod tests {
"evicted Arc should be freed when no external references remain"
);
}
#[tokio::test]
async fn test_shared_positive_hit_skips_resolver() {
let registry = DynamicRegistry::new();
let cache = Arc::new(MemoryCache::new());
registry.set_shared_cache(cache.clone());
let doc = LexiconDoc {
lexicon: 1,
id: nsid("pet.nel.sharedDoc"),
defs: HashMap::new(),
};
cache
.set(
&lexicon_doc_key(&nsid("pet.nel.sharedDoc")),
&serde_json::to_string(&doc).unwrap(),
POSITIVE_CACHE_TTL,
)
.await
.unwrap();
let resolved = registry
.resolve_and_cache_with(&nsid("pet.nel.sharedDoc"), |_| async move {
panic!("resolver mustn't run on a shared positive hit")
})
.await
.unwrap();
assert_eq!(resolved.id, "pet.nel.sharedDoc");
assert!(registry.get_cached(&nsid("pet.nel.sharedDoc")).is_some());
}
#[tokio::test]
async fn test_definitive_failure_writes_shared_negative_and_peers_mirror_it() {
let cache = Arc::new(MemoryCache::new());
let registry = DynamicRegistry::new();
registry.set_shared_cache(cache.clone());
let _ = registry
.resolve_and_cache_with(&nsid("pet.nel.gone"), |n| async move {
Err::<LexiconDoc, _>(ResolveError::SchemaNotFound {
nsid: n,
url: "https://oyster.cafe".to_string(),
})
})
.await;
assert!(
cache
.get(&lexicon_negative_key(&nsid("pet.nel.gone")))
.await
.is_some(),
"definitive failure must write the shared negative key"
);
let _ = registry
.resolve_and_cache_with(&nsid("pet.nel.transient"), |n| async move {
Err::<LexiconDoc, _>(ResolveError::DnsLookup {
domain: n.into_inner(),
reason: "simulated".to_string(),
})
})
.await;
assert!(
cache
.get(&lexicon_negative_key(&nsid("pet.nel.transient")))
.await
.is_none(),
"transient failure must stay out of the shared negative key"
);
let peer = DynamicRegistry::new();
peer.set_shared_cache(cache);
let err = peer
.resolve_and_cache_with(&nsid("pet.nel.gone"), |_| async move {
panic!("resolver mustn't run on a shared negative hit")
})
.await
.unwrap_err();
match err {
ResolveError::NegativelyCached { ttl_secs, .. } => assert!(
ttl_secs <= REFRESH_FAILURE_BACKOFF.as_secs(),
"local mirror must use the backoff TTL, got {}s",
ttl_secs
),
other => panic!("expected NegativelyCached, got: {}", other),
}
assert!(
peer.negative_remaining(&nsid("pet.nel.gone"))
.expect("local mirror exists")
<= REFRESH_FAILURE_BACKOFF
);
}
}
+5
View File
@@ -125,6 +125,11 @@ impl LexiconRegistry {
pub fn is_negative_cached(&self, nsid: &Nsid) -> bool {
self.dynamic.is_negative_cached(nsid)
}
#[cfg(feature = "resolve")]
pub fn set_shared_cache(&self, cache: Arc<dyn tranquil_infra::Cache>) {
self.dynamic.set_shared_cache(cache);
}
}
pub struct ResolvedRef {
+95 -82
View File
@@ -4,7 +4,10 @@ use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use std::sync::OnceLock;
use std::time::Duration;
use tranquil_types::{Did, Nsid};
use tranquil_types::did_doc::extract_pds_endpoint;
use tranquil_types::{
Did, Nsid, SchemaHostUrl, UrlKind, dns_guard, redirect_policy, url_kind, url_reach_permits,
};
static RESOLVER_CLIENT: OnceLock<Client> = OnceLock::new();
@@ -17,7 +20,8 @@ fn client() -> &'static Client {
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(4)
.pool_idle_timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::limited(3))
.redirect(redirect_policy(url_kind::SchemaHost::REACH_POLICY))
.dns_resolver(dns_guard(url_kind::SchemaHost::REACH_POLICY))
.build()
.expect("failed to build lexicon resolver HTTP client")
})
@@ -63,6 +67,8 @@ pub enum ResolveError {
NoPdsEndpoint { did: Did },
#[error("schema fetch failed from {url}: {reason}")]
SchemaFetch { url: String, reason: String },
#[error("no schema record for {nsid} at {url}")]
SchemaNotFound { nsid: Nsid, url: String },
#[error("schema deserialization failed: {0}")]
InvalidSchema(String),
#[error("schema resolution recently failed for {nsid}, cached for {ttl_secs}s")]
@@ -73,6 +79,23 @@ pub enum ResolveError {
LeaderAborted { nsid: Nsid },
}
impl ResolveError {
pub fn is_definitive(&self) -> bool {
match self {
Self::NoDid { .. }
| Self::NoPdsEndpoint { .. }
| Self::InvalidSchema(_)
| Self::SchemaNotFound { .. } => true,
Self::DnsLookup { .. }
| Self::DidResolution { .. }
| Self::SchemaFetch { .. }
| Self::NegativelyCached { .. }
| Self::NetworkDisabled
| Self::LeaderAborted { .. } => false,
}
}
}
pub fn nsid_to_authority(nsid: &Nsid) -> String {
let mut segments: Vec<&str> = nsid.split('.').collect();
segments.pop();
@@ -123,7 +146,7 @@ pub async fn resolve_did_from_dns(authority: &str) -> Result<Did, ResolveError>
pub async fn resolve_pds_endpoint(
did: &Did,
plc_directory_url: Option<&str>,
) -> Result<String, ResolveError> {
) -> Result<SchemaHostUrl, ResolveError> {
let plc_base = plc_directory_url.unwrap_or(DEFAULT_PLC_DIRECTORY);
let url = match did
@@ -131,7 +154,20 @@ pub async fn resolve_pds_endpoint(
.and_then(|(_, rest)| rest.split_once(':'))
{
Some(("plc", _)) => format!("{}/{}", plc_base.trim_end_matches('/'), did),
Some(("web", domain)) => format!("https://{}/.well-known/did.json", domain),
Some(("web", domain)) => {
let url = format!("https://{}/.well-known/did.json", domain);
let permitted = reqwest::Url::parse(&url)
.is_ok_and(|u| url_reach_permits(&u, url_kind::SchemaHost::REACH_POLICY));
match permitted {
true => url,
false => {
return Err(ResolveError::DidResolution {
did: did.clone(),
reason: "did:web host is outside the allowed host reach".to_string(),
});
}
}
}
_ => {
return Err(ResolveError::DidResolution {
did: did.clone(),
@@ -162,39 +198,29 @@ pub async fn resolve_pds_endpoint(
reason: e.to_string(),
})?;
extract_pds_endpoint(&doc).ok_or_else(|| ResolveError::NoPdsEndpoint { did: did.clone() })
extract_pds_endpoint(&doc).map_err(|_| ResolveError::NoPdsEndpoint { did: did.clone() })
}
fn extract_pds_endpoint(doc: &serde_json::Value) -> Option<String> {
doc.get("service")
.and_then(|s| s.as_array())
.and_then(|services| {
services.iter().find_map(|svc| {
let is_pds = svc
.get("type")
.and_then(|t| t.as_str())
.is_some_and(|t| t == "AtprotoPersonalDataServer");
is_pds
.then(|| svc.get("serviceEndpoint").and_then(|ep| ep.as_str()))?
.map(|s| s.to_string())
})
})
fn is_record_absent(xrpc_error: &str, xrpc_message: &str) -> bool {
xrpc_error == "RecordNotFound"
|| xrpc_error == "InvalidRequest" && xrpc_message.starts_with("Could not locate record")
}
pub async fn fetch_schema_from_pds(
pds_endpoint: &str,
pds_endpoint: &SchemaHostUrl,
did: &Did,
nsid: &Nsid,
) -> Result<LexiconDoc, ResolveError> {
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
pds_endpoint.trim_end_matches('/'),
urlencoding::encode(did.as_str()),
urlencoding::encode(nsid.as_str())
);
let mut request_url = pds_endpoint.endpoint("xrpc/com.atproto.repo.getRecord");
request_url
.query_pairs_mut()
.append_pair("repo", did.as_str())
.append_pair("collection", "com.atproto.lexicon.schema")
.append_pair("rkey", nsid.as_str());
let url = request_url.to_string();
let resp = client()
.get(&url)
.get(request_url)
.send()
.await
.map_err(|e| ResolveError::SchemaFetch {
@@ -204,10 +230,27 @@ pub async fn fetch_schema_from_pds(
let status = resp.status();
if !status.is_success() {
return Err(ResolveError::SchemaFetch {
url,
reason: format!("HTTP {}", status),
});
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
.await
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
.unwrap_or(serde_json::Value::Null);
let field = |name: &str| {
body.get(name)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
return match is_record_absent(&field("error"), &field("message")) {
true => Err(ResolveError::SchemaNotFound {
nsid: nsid.clone(),
url,
}),
false => Err(ResolveError::SchemaFetch {
url,
reason: format!("HTTP {}", status),
}),
};
}
let body = read_body_limited(resp, MAX_RESPONSE_BYTES)
@@ -292,6 +335,27 @@ mod tests {
s.parse().unwrap()
}
#[test]
fn is_record_absent_recognizes_only_the_reference_pds_absence_shapes() {
assert!(is_record_absent(
"RecordNotFound",
"Could not locate record: at://did:plc:nel/com.atproto.lexicon.schema/x"
));
assert!(is_record_absent("RecordNotFound", ""));
assert!(is_record_absent(
"InvalidRequest",
"Could not locate record"
));
assert!(!is_record_absent(
"InvalidRequest",
"Error: rkey must be a valid record key"
));
assert!(!is_record_absent("InvalidRequest", ""));
assert!(!is_record_absent("InternalServerError", ""));
assert!(!is_record_absent("RateLimitExceeded", ""));
assert!(!is_record_absent("", ""));
}
#[test]
fn test_nsid_to_authority() {
assert_eq!(
@@ -316,57 +380,6 @@ mod tests {
);
}
#[test]
fn test_extract_pds_endpoint_valid() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_multiple_services() {
let doc = serde_json::json!({
"service": [
{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
]
});
assert_eq!(
extract_pds_endpoint(&doc),
Some("https://pds.example.com".to_string())
);
}
#[test]
fn test_extract_pds_endpoint_missing() {
let doc = serde_json::json!({
"service": [{
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
}]
});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_extract_pds_endpoint_no_services() {
let doc = serde_json::json!({});
assert_eq!(extract_pds_endpoint(&doc), None);
}
#[test]
fn test_validate_fetched_schema_ok() {
let doc = LexiconDoc {
+15 -15
View File
@@ -1,8 +1,8 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tranquil_types::Nsid;
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexiconDoc {
pub lexicon: u32,
pub id: Nsid,
@@ -10,7 +10,7 @@ pub struct LexiconDoc {
pub defs: HashMap<String, LexDef>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LexDef {
#[serde(rename = "record")]
@@ -35,14 +35,14 @@ pub enum LexDef {
PermissionSet {},
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexRecord {
#[serde(default)]
pub key: Option<String>,
pub record: LexObject,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexObject {
#[serde(default)]
pub required: Vec<String>,
@@ -52,7 +52,7 @@ pub struct LexObject {
pub properties: HashMap<String, LexProperty>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum LexProperty {
#[serde(rename = "string")]
@@ -79,7 +79,7 @@ pub enum LexProperty {
Object(LexObject),
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexString {
#[serde(default)]
@@ -102,7 +102,7 @@ pub struct LexString {
pub default: Option<String>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexInteger {
#[serde(default)]
pub minimum: Option<i64>,
@@ -116,7 +116,7 @@ pub struct LexInteger {
pub const_value: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBytes {
#[serde(default)]
@@ -125,7 +125,7 @@ pub struct LexBytes {
pub min_length: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexBlob {
#[serde(default)]
@@ -134,7 +134,7 @@ pub struct LexBlob {
pub max_size: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexArray {
pub items: Box<LexProperty>,
@@ -144,7 +144,7 @@ pub struct LexArray {
pub max_length: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct LexUnion {
#[serde(default)]
pub refs: Vec<String>,
@@ -152,14 +152,14 @@ pub struct LexUnion {
pub closed: bool,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexRef {
#[serde(rename = "ref")]
pub reference: String,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StringFormat {
#[serde(rename = "did")]
Did,
@@ -204,6 +204,6 @@ pub fn parse_ref(reference: &str) -> ParsedRef<'_> {
}
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LexStringDef {}
@@ -74,7 +74,7 @@ async fn test_resolve_pds_endpoint_from_plc() {
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
assert_eq!(endpoint.as_str(), "https://pds.example.com");
}
#[tokio::test]
@@ -130,14 +130,17 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
"id": did,
"service": [
{
"id": "#atproto_labeler",
"type": "AtprotoLabeler",
"serviceEndpoint": "https://labeler.example.com"
},
{
"id": "#bsky_notif",
"type": "BskyNotificationService",
"serviceEndpoint": "https://notify.example.com"
},
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://pds.example.com"
}
@@ -149,7 +152,7 @@ async fn test_resolve_pds_endpoint_multiple_services_picks_pds() {
let endpoint = resolve_pds_endpoint(&did.parse().unwrap(), Some(&plc_server.uri()))
.await
.unwrap();
assert_eq!(endpoint, "https://pds.example.com");
assert_eq!(endpoint.as_str(), "https://pds.example.com");
}
#[tokio::test]
@@ -168,7 +171,7 @@ async fn test_fetch_schema_from_pds_success() {
.await;
let doc = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -195,7 +198,7 @@ async fn test_fetch_schema_missing_value_field() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -222,7 +225,7 @@ async fn test_fetch_schema_invalid_lexicon_json() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -352,7 +355,7 @@ async fn test_pds_trailing_slash_handled() {
let pds_url_with_slash = format!("{}/", pds_server.uri());
let doc = fetch_schema_from_pds(
&pds_url_with_slash,
&pds_url_with_slash.parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
@@ -377,7 +380,7 @@ async fn test_fetch_schema_error_status_gives_meaningful_error() {
.await;
let result = fetch_schema_from_pds(
&pds_server.uri(),
&pds_server.uri().parse().unwrap(),
&did.parse().unwrap(),
&nsid.parse().unwrap(),
)
+4
View File
@@ -37,3 +37,7 @@ webauthn-rs = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tranquil-infra = { workspace = true, features = ["testing"] }
[features]
bsky = []
@@ -10,6 +10,8 @@ pub struct ScopeInfo {
pub display_name: String,
pub granted: Option<bool>,
pub restricted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_scope: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -118,7 +120,7 @@ pub async fn consent_get(
};
let did = flow_with_user.did().clone();
let client_cache = ClientMetadataCache::new(3600);
let client_cache = &state.client_metadata_cache;
let client_metadata = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -186,20 +188,29 @@ pub async fn consent_get(
let grant_scope_str: Option<&str> =
delegation_grant.as_ref().map(|g| g.granted_scopes.as_str());
let is_restricted = |scope: &str| -> bool {
grant_scope_str.is_some_and(|g| !tranquil_pds::delegation::grant_covers(g, scope))
let coverage_of = |scope: &str| -> tranquil_pds::delegation::GrantCoverage {
match grant_scope_str {
Some(g) => tranquil_pds::delegation::grant_coverage(g, scope),
None => tranquil_pds::delegation::GrantCoverage::Full,
}
};
let make_scope_info = |scope: &str| -> ScopeInfo {
let (restricted, effective_scope) = match coverage_of(scope) {
tranquil_pds::delegation::GrantCoverage::Full => (false, None),
tranquil_pds::delegation::GrantCoverage::Narrowed(narrowed) => (false, Some(narrowed)),
tranquil_pds::delegation::GrantCoverage::Withheld => (true, None),
};
let described = effective_scope.as_deref().unwrap_or(scope);
let (category, required, description, display_name) =
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(scope) {
let desc = if scope == "atproto" && has_granular_scopes {
if let Some(def) = tranquil_pds::oauth::scopes::SCOPE_DEFINITIONS.get(described) {
let desc = if described == "atproto" && has_granular_scopes {
"AT Protocol baseline scope (permissions determined by selected options below)"
.to_string()
} else {
def.description.to_string()
};
let name = if scope == "atproto" && has_granular_scopes {
let name = if described == "atproto" && has_granular_scopes {
"AT Protocol Access".to_string()
} else {
def.display_name.to_string()
@@ -210,19 +221,19 @@ pub async fn consent_get(
desc,
name,
)
} else if scope.starts_with("ref:") {
} else if described.starts_with("ref:") {
(
"Reference".to_string(),
false,
"Referenced scope".to_string(),
scope.to_string(),
described.to_string(),
)
} else {
(
"Other".to_string(),
false,
format!("Access to {}", scope),
scope.to_string(),
format!("Access to {}", described),
described.to_string(),
)
};
let granted = pref_map.get(scope).copied();
@@ -233,7 +244,8 @@ pub async fn consent_get(
description,
display_name,
granted,
restricted: is_restricted(scope),
restricted,
effective_scope,
}
};
@@ -80,7 +80,7 @@ pub async fn authorize_get(
"Authorization request has expired. Please start a new request.",
);
}
let client_cache = ClientMetadataCache::new(3600);
let client_cache = &state.client_metadata_cache;
let client_name = client_cache
.get(&request_data.parameters.client_id)
.await
@@ -14,8 +14,7 @@ use tranquil_db_traits::{ScopePreference, WebauthnChallengeType};
use tranquil_pds::auth::{BareLoginIdentifier, NormalizedLoginIdentifier};
use tranquil_pds::comms::comms_repo::enqueue_2fa_code;
use tranquil_pds::oauth::{
AuthFlow, ClientMetadataCache, DeviceData, DeviceId, OAuthError, Prompt, SessionId,
db::should_show_consent,
AuthFlow, DeviceData, DeviceId, OAuthError, Prompt, SessionId, db::should_show_consent,
};
use tranquil_pds::rate_limit::{
OAuthAuthorizeLimit, OAuthRateLimited, OAuthRegisterCompleteLimit, TotpVerifyLimit,
@@ -33,36 +33,12 @@ pub async fn resolve_effective_scopes(
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tranquil_pds::cache::{Cache, CacheError};
use tranquil_infra::MemoryCache;
use tranquil_pds::cache::Cache;
#[derive(Default)]
struct MapCache(Mutex<HashMap<String, String>>);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, k: &str) -> Option<String> {
self.0.lock().unwrap().get(k).cloned()
}
async fn set(&self, k: &str, v: &str, _t: Duration) -> Result<(), CacheError> {
self.0.lock().unwrap().insert(k.into(), v.into());
Ok(())
}
async fn delete(&self, k: &str) -> Result<(), CacheError> {
self.0.lock().unwrap().remove(k);
Ok(())
}
async fn get_bytes(&self, _k: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
Ok(())
}
}
fn cache_with(nsid: &str, scopes: &str) -> MapCache {
let c = MapCache::default();
async fn cache_with(nsid: &str, scopes: &str) -> MemoryCache {
let c = MemoryCache::new();
let key = tranquil_pds::cache_keys::permission_set_key(
&tranquil_types::Nsid::new(nsid).unwrap(),
None,
@@ -74,7 +50,7 @@ mod tests {
"refreshed_at": chrono::Utc::now().timestamp(),
})
.to_string();
c.0.lock().unwrap().insert(key, json);
let _ = c.set(&key, &json, Duration::from_secs(3600)).await;
c
}
@@ -83,7 +59,8 @@ mod tests {
let c = cache_with(
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
)
.await;
let eff = resolve_effective_scopes(
&c,
"atproto include:io.atcr.authFullApp",
@@ -104,7 +81,8 @@ mod tests {
let c = cache_with(
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
)
.await;
let granted = DbScope::new("atproto repo:* blob:*/* account:*?action=manage").unwrap();
let eff = resolve_effective_scopes(
&c,
@@ -13,7 +13,8 @@ use tranquil_pds::rate_limit::{LoginLimit, OAuthRateLimited, TotpVerifyLimit};
use tranquil_pds::state::AppState;
use tranquil_pds::types::PlainPassword;
use tranquil_pds::util::ClientIp;
use tranquil_types::did_doc::{extract_handle, extract_pds_endpoint};
use tranquil_types::did_doc::{PdsEndpointError, extract_handle, extract_pds_endpoint};
use tranquil_types::url_kind;
use tranquil_types::{Did, RequestId};
#[allow(clippy::result_large_err)]
@@ -231,11 +232,17 @@ pub async fn delegation_auth(
}
};
let pds_url = match extract_pds_endpoint(&did_doc) {
Some(url) => url,
None => {
let pds_url = match extract_pds_endpoint::<url_kind::Pds>(&did_doc) {
Ok(url) => url,
Err(PdsEndpointError::Missing) => {
return DelegationAuthResponse::err("Controller has no PDS endpoint");
}
Err(PdsEndpointError::Invalid(e)) => {
tracing::warn!(controller = %controller_did, error = %e, "Controller PDS endpoint rejected");
return DelegationAuthResponse::err(
"Controller PDS endpoint isn't a usable https URL",
);
}
};
let hostname = &tranquil_config::get().server.hostname;
@@ -447,7 +454,7 @@ pub async fn delegation_auth_token(
#[derive(Debug, Deserialize)]
pub struct CrossPdsCallbackParams {
pub code: tranquil_types::AuthorizationCode,
pub state: String,
pub state: tranquil_types::CrossPdsState,
pub iss: Option<String>,
}
@@ -474,7 +481,7 @@ pub async fn delegation_callback(
if let Some(ref expected_issuer) = auth_state.expected_issuer {
match &params.iss {
Some(iss) if iss != expected_issuer => {
Some(iss) if iss.as_str() != expected_issuer.as_str() => {
tracing::error!(
"Cross-PDS issuer mismatch: expected {}, got {}",
expected_issuer,
@@ -3,8 +3,8 @@ use axum::{Json, extract::State, http::HeaderMap};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use tranquil_pds::oauth::{
AuthorizationRequestParameters, ClientAuth, ClientMetadataCache, CodeChallengeMethod,
OAuthError, Prompt, RequestData, RequestId, ResponseMode, ResponseType,
AuthorizationRequestParameters, ClientAuth, CodeChallengeMethod, OAuthError, Prompt,
RequestData, RequestId, ResponseMode, ResponseType,
scopes::{ParsedScope, parse_scope},
};
use tranquil_pds::rate_limit::{OAuthParLimit, OAuthRateLimited};
@@ -80,7 +80,7 @@ pub async fn pushed_authorization_request(
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
let code_challenge_method =
parse_code_challenge_method(request.code_challenge_method.as_deref())?;
let client_cache = ClientMetadataCache::new(3600);
let client_cache = &state.client_metadata_cache;
let client_metadata = client_cache.get(&request.client_id).await?;
client_cache.validate_redirect_uri(&client_metadata, &request.redirect_uri)?;
let client_auth = determine_client_auth(&request)?;
@@ -8,8 +8,7 @@ use chrono::{Duration, Utc};
use tranquil_db_traits::RefreshTokenLookup;
use tranquil_pds::config::AuthConfig;
use tranquil_pds::oauth::{
AuthFlow, ClientAuth, ClientMetadataCache, DPoPVerifier, OAuthError, RefreshToken, TokenData,
TokenId,
AuthFlow, ClientAuth, DPoPVerifier, OAuthError, RefreshToken, TokenData, TokenId,
db::{enforce_token_limit_for_user, lookup_refresh_token},
verify_client_auth,
};
@@ -63,7 +62,7 @@ pub async fn handle_authorization_code_grant(
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
}
let did = authorized.did.clone();
let client_metadata_cache = ClientMetadataCache::new(3600);
let client_metadata_cache = &state.client_metadata_cache;
let client_metadata = client_metadata_cache.get(&authorized.client_id).await?;
let client_auth = match &request.client_auth {
RequestClientAuth::PrivateKeyJwt {
@@ -85,7 +84,7 @@ pub async fn handle_authorization_code_grant(
},
RequestClientAuth::None { .. } => ClientAuth::None,
};
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
verify_client_auth(client_metadata_cache, &client_metadata, &client_auth).await?;
verify_pkce(&authorized.parameters.code_challenge, &code_verifier)?;
if let Some(req_redirect_uri) = &redirect_uri
&& req_redirect_uri != &authorized.parameters.redirect_uri
@@ -43,7 +43,8 @@ pub fn create_access_token_with_delegation(
let issuer = format!("https://{}", pds_hostname);
let now = Utc::now().timestamp();
let exp = now + ACCESS_TOKEN_EXPIRY_SECONDS;
let actual_scope = scope.unwrap_or("atproto");
let actual_scope = tranquil_pds::auth::encode_scope(scope.unwrap_or("atproto"))
.map_err(|_| OAuthError::InvalidScope("Scope too large".to_string()))?;
let mut payload = json!({
"iss": issuer,
"sub": sub.as_str(),
@@ -1209,20 +1209,23 @@ pub async fn complete_registration(
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
let app_password = generate_app_password();
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
tranquil-types = { workspace = true }
tranquil-infra = { workspace = true, features = ["cache-keys"] }
anyhow = { workspace = true }
sqlx = { workspace = true }
+106 -90
View File
@@ -1,12 +1,19 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use std::time::Duration;
use crate::OAuthError;
use crate::types::ClientAuth;
use tranquil_types::ClientId;
use tranquil_infra::cache_keys::{
oauth_client_jwks_cooldown_key, oauth_client_jwks_key, oauth_client_meta_key,
};
use tranquil_infra::{Cache, cached_json, write_json};
use tranquil_types::{
ClientId, JwksUri, ReachPolicy, dns_guard, redirect_policy, url_reach_permits,
};
const JWKS_REFRESH_COOLDOWN: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
@@ -30,8 +37,12 @@ pub struct ClientMetadata {
pub dpop_bound_access_tokens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "tranquil_types::http_url::deserialize_optional"
)]
pub jwks_uri: Option<JwksUri>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_type: Option<String>,
}
@@ -58,33 +69,23 @@ impl Default for ClientMetadata {
#[derive(Clone)]
pub struct ClientMetadataCache {
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
jwks_cache: Arc<RwLock<HashMap<String, CachedJwks>>>,
cache: Arc<dyn Cache>,
http_client: Client,
cache_ttl_secs: u64,
}
struct CachedMetadata {
metadata: ClientMetadata,
cached_at: std::time::Instant,
}
struct CachedJwks {
jwks: serde_json::Value,
cached_at: std::time::Instant,
cache_ttl: Duration,
}
impl ClientMetadataCache {
pub fn new(cache_ttl_secs: u64) -> Self {
pub fn new(cache: Arc<dyn Cache>, cache_ttl: Duration) -> Self {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
jwks_cache: Arc::new(RwLock::new(HashMap::new())),
cache,
http_client: {
let builder = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.pool_max_idle_per_host(10)
.pool_idle_timeout(std::time::Duration::from_secs(90))
.redirect(redirect_policy(ReachPolicy::DEBUG_LOOPBACK))
.dns_resolver(dns_guard(ReachPolicy::DEBUG_LOOPBACK))
.user_agent(concat!(
"Tranquil-PDS/",
env!("CARGO_PKG_VERSION"),
@@ -92,9 +93,11 @@ impl ClientMetadataCache {
));
#[cfg(feature = "native-tls-roots")]
let builder = builder.danger_accept_invalid_certs(true);
builder.build().unwrap_or_else(|_| Client::new())
builder
.build()
.expect("failed to build client metadata HTTP client")
},
cache_ttl_secs,
cache_ttl,
}
}
@@ -139,7 +142,7 @@ impl ClientMetadataCache {
response_types: vec!["code".into()],
scope,
token_endpoint_auth_method: Some("none".into()),
dpop_bound_access_tokens: Some(false),
dpop_bound_access_tokens: Some(true),
jwks: None,
jwks_uri: None,
application_type: Some("native".into()),
@@ -150,26 +153,13 @@ impl ClientMetadataCache {
if Self::is_loopback_client(client_id) {
return Self::build_loopback_metadata(client_id);
}
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(client_id.as_str())
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
{
return Ok(cached.metadata.clone());
}
}
let metadata = self.fetch_metadata(client_id).await?;
{
let mut cache = self.cache.write().await;
cache.insert(
client_id.to_string(),
CachedMetadata {
metadata: metadata.clone(),
cached_at: std::time::Instant::now(),
},
);
}
Ok(metadata)
cached_json(
self.cache.as_ref(),
&oauth_client_meta_key(client_id),
self.cache_ttl,
|| self.fetch_metadata(client_id),
)
.await
}
pub async fn get_jwks(
@@ -181,43 +171,57 @@ impl ClientMetadataCache {
}
let jwks_uri = metadata.jwks_uri.as_ref().ok_or_else(|| {
OAuthError::InvalidClient(
"Client using private_key_jwt must have jwks or jwks_uri".to_string(),
"Client using private_key_jwt must have jwks or a usable jwks_uri".to_string(),
)
})?;
{
let cache = self.jwks_cache.read().await;
if let Some(cached) = cache.get(jwks_uri)
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs
{
return Ok(cached.jwks.clone());
cached_json(
self.cache.as_ref(),
&oauth_client_jwks_key(jwks_uri),
self.cache_ttl,
|| self.fetch_jwks(jwks_uri),
)
.await
}
async fn refresh_jwks(
&self,
metadata: &ClientMetadata,
) -> Result<Option<serde_json::Value>, OAuthError> {
match (&metadata.jwks, &metadata.jwks_uri) {
(None, Some(jwks_uri)) => {
let cooldown_key = oauth_client_jwks_cooldown_key(jwks_uri);
if self.cache.get(&cooldown_key).await.is_some() {
return Ok(None);
}
let _ = self
.cache
.set(&cooldown_key, "1", JWKS_REFRESH_COOLDOWN)
.await;
self.fetch_and_store_jwks(jwks_uri).await.map(Some)
}
_ => Ok(None),
}
}
async fn fetch_and_store_jwks(
&self,
jwks_uri: &JwksUri,
) -> Result<serde_json::Value, OAuthError> {
let jwks = self.fetch_jwks(jwks_uri).await?;
{
let mut cache = self.jwks_cache.write().await;
cache.insert(
jwks_uri.clone(),
CachedJwks {
jwks: jwks.clone(),
cached_at: std::time::Instant::now(),
},
);
}
write_json(
self.cache.as_ref(),
&oauth_client_jwks_key(jwks_uri),
&jwks,
self.cache_ttl,
)
.await;
Ok(jwks)
}
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
if !jwks_uri.starts_with("https://")
&& (!jwks_uri.starts_with("http://")
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
{
return Err(OAuthError::InvalidClient(
"jwks_uri must use https (except for localhost)".to_string(),
));
}
async fn fetch_jwks(&self, jwks_uri: &JwksUri) -> Result<serde_json::Value, OAuthError> {
let response = self
.http_client
.get(jwks_uri)
.get(jwks_uri.as_str())
.header("Accept", "application/json")
.send()
.await
@@ -243,22 +247,16 @@ impl ClientMetadataCache {
}
async fn fetch_metadata(&self, client_id: &ClientId) -> Result<ClientMetadata, OAuthError> {
if !client_id.starts_with("http://") && !client_id.starts_with("https://") {
let url = reqwest::Url::parse(client_id)
.map_err(|_| OAuthError::InvalidClient("client_id must be a URL".to_string()))?;
if !url_reach_permits(&url, ReachPolicy::DEBUG_LOOPBACK) {
return Err(OAuthError::InvalidClient(
"client_id must be a URL".to_string(),
));
}
if client_id.starts_with("http://")
&& !client_id.contains("localhost")
&& !client_id.contains("127.0.0.1")
{
return Err(OAuthError::InvalidClient(
"Non-localhost client_id must use https".to_string(),
"client_id must be an https URL inside the allowed host reach".to_string(),
));
}
let response = self
.http_client
.get(client_id.as_str())
.get(url)
.header("Accept", "application/json")
.send()
.await
@@ -514,7 +512,29 @@ async fn verify_private_key_jwt_async(
"client_assertion iat is in the future".to_string(),
));
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let signature_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
let jwks = cache.get_jwks(metadata).await?;
match verify_assertion_signature(&jwks, kid, alg, &signing_input, &signature_bytes) {
Ok(()) => Ok(()),
Err(cached_failure) => match cache.refresh_jwks(metadata).await {
Ok(Some(fresh)) => {
verify_assertion_signature(&fresh, kid, alg, &signing_input, &signature_bytes)
}
Ok(None) | Err(_) => Err(cached_failure),
},
}
}
fn verify_assertion_signature(
jwks: &serde_json::Value,
kid: Option<&str>,
alg: &str,
signing_input: &str,
signature: &[u8],
) -> Result<(), OAuthError> {
let keys = jwks
.get("keys")
.and_then(|k| k.as_array())
@@ -531,10 +551,6 @@ async fn verify_private_key_jwt_async(
"No matching key found in client JWKS".to_string(),
));
}
let signing_input = format!("{}.{}", parts[0], parts[1]);
let signature_bytes = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| OAuthError::InvalidClient("Invalid signature encoding".to_string()))?;
matching_keys
.into_iter()
.filter(|key| {
@@ -544,12 +560,12 @@ async fn verify_private_key_jwt_async(
.find_map(|key| {
let kty = key.get("kty").and_then(|k| k.as_str()).unwrap_or("");
match (alg, kty) {
("ES256", "EC") => verify_es256(key, &signing_input, &signature_bytes).ok(),
("ES384", "EC") => verify_es384(key, &signing_input, &signature_bytes).ok(),
("ES256", "EC") => verify_es256(key, signing_input, signature).ok(),
("ES384", "EC") => verify_es384(key, signing_input, signature).ok(),
("RS256" | "RS384" | "RS512", "RSA") => {
verify_rsa(alg, key, &signing_input, &signature_bytes).ok()
verify_rsa(alg, key, signing_input, signature).ok()
}
("EdDSA", "OKP") => verify_eddsa(key, &signing_input, &signature_bytes).ok(),
("EdDSA", "OKP") => verify_eddsa(key, signing_input, signature).ok(),
_ => None,
}
})
+5 -5
View File
@@ -1,7 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tranquil_types::{ClientId, Did};
use tranquil_types::{AuthServerEndpoint, ClientId, Did, Issuer};
pub use tranquil_types::{AuthorizationCode, DeviceId, RefreshToken, RequestId, TokenId};
@@ -195,9 +195,9 @@ pub struct ProtectedResourceMetadata {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizationServerMetadata {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub issuer: Issuer,
pub authorization_endpoint: AuthServerEndpoint,
pub token_endpoint: AuthServerEndpoint,
pub jwks_uri: String,
pub registration_endpoint: Option<String>,
pub scopes_supported: Option<Vec<String>>,
@@ -206,7 +206,7 @@ pub struct AuthorizationServerMetadata {
pub grant_types_supported: Option<Vec<String>>,
pub token_endpoint_auth_methods_supported: Option<Vec<String>>,
pub code_challenge_methods_supported: Option<Vec<String>>,
pub pushed_authorization_request_endpoint: Option<String>,
pub pushed_authorization_request_endpoint: Option<AuthServerEndpoint>,
pub require_pushed_authorization_requests: Option<bool>,
pub dpop_signing_alg_values_supported: Option<Vec<String>>,
pub authorization_response_iss_parameter_supported: Option<bool>,
+12 -14
View File
@@ -7,7 +7,6 @@ license.workspace = true
[dependencies]
tranquil-types = { workspace = true }
tranquil-config = { workspace = true }
tranquil-crypto = { workspace = true }
tranquil-storage = { workspace = true }
tranquil-cache = { workspace = true }
tranquil-repo = { workspace = true }
@@ -16,7 +15,7 @@ tranquil-auth = { workspace = true }
tranquil-oauth = { workspace = true }
tranquil-comms = { workspace = true }
tranquil-signal = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db = { workspace = true, features = ["postgres"] }
tranquil-db-traits = { workspace = true }
tranquil-store = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
@@ -29,13 +28,11 @@ axum = { workspace = true }
base32 = { workspace = true }
base64 = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
hex = { workspace = true }
futures-util = { workspace = true }
governor = { workspace = true }
hickory-resolver = { workspace = true }
@@ -43,7 +40,6 @@ hkdf = { workspace = true }
hmac = { workspace = true }
http = { workspace = true }
image = { workspace = true }
infer = { workspace = true }
ipld-core = { workspace = true }
iroh-car = { workspace = true }
jacquard-common = { workspace = true }
@@ -57,7 +53,6 @@ multihash = { workspace = true }
p256 = { workspace = true }
parking_lot = { workspace = true }
rand = { workspace = true }
redis = { workspace = true, optional = true }
regex = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
@@ -70,7 +65,6 @@ sqlx = { workspace = true }
subtle = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
@@ -80,20 +74,19 @@ urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
webauthn-rs-proto = { workspace = true }
zip = { workspace = true }
aws-config = { workspace = true, optional = true }
aws-sdk-s3 = { workspace = true, optional = true }
[features]
default = ["frontend", "s3", "valkey"]
bsky = ["bsky-support"]
bsky-support = []
external-infra = []
s3-storage = ["tranquil-storage/s3", "dep:aws-config", "dep:aws-sdk-s3"]
s3 = ["s3-storage"]
valkey = ["tranquil-cache/valkey", "dep:redis"]
postgres = ["tranquil-db/postgres"]
s3 = ["tranquil-storage/s3"]
valkey = ["tranquil-cache/valkey"]
frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
[dev-dependencies]
tranquil-infra = { workspace = true, features = ["testing"] }
tempfile = "3"
ciborium = { workspace = true }
ctor = { workspace = true }
@@ -105,3 +98,8 @@ tranquil-api = { workspace = true }
tranquil-oauth-server = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
wiremock = { workspace = true }
hex = { workspace = true }
tokio-tungstenite = { workspace = true }
aws-config = { workspace = true }
aws-sdk-s3 = { workspace = true }
redis = { workspace = true }
+20 -10
View File
@@ -5,7 +5,7 @@ use std::sync::LazyLock;
use crate::api::error::ApiError;
use crate::api::proxy_client::proxy_client;
use crate::state::AppState;
use crate::types::{Did, Nsid};
use crate::types::{Did, DidRef, Nsid};
use crate::util::get_header_str;
use axum::{
body::Bytes,
@@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService};
use tracing::{error, info, warn};
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"app.bsky.actor.getPreferences",
"app.bsky.actor.putPreferences",
let mut methods: HashSet<&str> = [
"com.atproto.admin.deleteAccount",
"com.atproto.admin.disableAccountInvites",
"com.atproto.admin.disableInviteCodes",
@@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
"com.atproto.temp.dereferenceScope",
]
.into_iter()
.collect()
.collect();
// BSKY: the Bluesky preferences API must be implemented by PDSs
if cfg!(feature = "bsky-support") {
methods.insert("app.bsky.actor.getPreferences");
methods.insert("app.bsky.actor.putPreferences");
};
methods
});
fn is_protected_method(method: &str) -> bool {
@@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool {
}
/// Fetch the `feed` generator record from the AppView and return its `did`.
#[cfg(feature = "bsky-support")]
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<Did> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
@@ -201,6 +206,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
}
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
#[cfg(feature = "bsky")]
if tranquil_config::get().server.age_assurance_override
&& (path.ends_with("app.bsky.ageassurance.getState")
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
@@ -328,7 +334,8 @@ async fn proxy_handler(
},
};
// getFeed must be audienced to the feed generator, not the AppView.
// BSKY: getFeed must be audienced to the feed generator, not the AppView.
#[cfg(feature = "bsky-support")]
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (
@@ -339,21 +346,24 @@ async fn proxy_handler(
),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
to mint an AppView-audienced token"
"getFeed proxy refuses to mint an AppView-audienced token \
because feed generator DID resolution failed"
);
return ApiError::InvalidRequest("Could not resolve feed".into())
return ApiError::InvalidRequest("Couldn't resolve feed".into())
.into_response();
}
}
} else {
(resolved.did.clone(), method_nsid.clone())
};
#[cfg(not(feature = "bsky-support"))]
let (token_aud, token_lxm) = (resolved.did.clone(), method_nsid.clone());
match crate::auth::create_service_token(
&auth_user.did,
&token_aud,
&DidRef::from(token_aud),
Some(&token_lxm),
None,
&key_bytes,
) {
Ok(new_token) => {
+11 -1
View File
@@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError {
}
impl std::error::Error for SsrfError {}
// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts
// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350
#[cfg(feature = "bsky-support")]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
@@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
http::header::CONTENT_TYPE,
]
});
#[cfg(not(feature = "bsky-support"))]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
http::header::CONTENT_TYPE,
]
});
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
[
crate::util::HEADER_ATPROTO_REPO_REV,
+14 -92
View File
@@ -2,32 +2,14 @@ use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::cache_keys::email_token_key;
use crate::types::Did;
use crate::util::{generate_token_code, normalize_token_code};
pub use tranquil_types::EmailTokenPurpose;
const TOKEN_TTL_SECS: u64 = 900;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailTokenPurpose {
UpdateEmail,
ConfirmEmail,
DeleteAccount,
ResetPassword,
PlcOperation,
}
impl EmailTokenPurpose {
fn as_str(&self) -> &'static str {
match self {
Self::UpdateEmail => "update_email",
Self::ConfirmEmail => "confirm_email",
Self::DeleteAccount => "delete_account",
Self::ResetPassword => "reset_password",
Self::PlcOperation => "plc_operation",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TokenData {
token: String,
@@ -42,10 +24,6 @@ pub enum TokenError {
ExpiredToken,
}
fn cache_key(did: &Did, purpose: EmailTokenPurpose) -> String {
format!("email_token:{}:{}", purpose.as_str(), did)
}
fn current_timestamp() -> u64 {
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
@@ -69,7 +47,7 @@ pub async fn create_email_token(
cache
.set(
&cache_key(did, purpose),
&email_token_key(did, purpose),
&json,
Duration::from_secs(TOKEN_TTL_SECS),
)
@@ -89,7 +67,7 @@ pub async fn validate_email_token(
return Err(TokenError::CacheUnavailable);
}
let key = cache_key(did, purpose);
let key = email_token_key(did, purpose);
let json = cache.get(&key).await.ok_or(TokenError::InvalidToken)?;
let data: TokenData = serde_json::from_str(&json).map_err(|_| TokenError::InvalidToken)?;
@@ -112,7 +90,7 @@ pub async fn validate_email_token(
}
pub async fn delete_email_token(cache: &dyn Cache, did: &Did, purpose: EmailTokenPurpose) {
let _ = cache.delete(&cache_key(did, purpose)).await;
let _ = cache.delete(&email_token_key(did, purpose)).await;
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
@@ -128,67 +106,11 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::CacheError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockCache {
data: Mutex<HashMap<String, (String, u64)>>,
}
impl MockCache {
fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl Cache for MockCache {
async fn get(&self, key: &str) -> Option<String> {
let data = self.data.lock().unwrap();
let now = current_timestamp();
data.get(key)
.filter(|(_, exp)| *exp > now)
.map(|(v, _)| v.clone())
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
let mut data = self.data.lock().unwrap();
let expires = current_timestamp() + ttl.as_secs();
data.insert(key.to_string(), (value.to_string(), expires));
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
let mut data = self.data.lock().unwrap();
data.remove(key);
Ok(())
}
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(
&self,
_key: &str,
_value: &[u8],
_ttl: Duration,
) -> Result<(), CacheError> {
Ok(())
}
fn is_available(&self) -> bool {
true
}
}
use tranquil_infra::MemoryCache;
#[tokio::test]
async fn test_create_and_validate_token() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
@@ -205,7 +127,7 @@ mod tests {
#[tokio::test]
async fn test_token_consumed_after_use() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
@@ -223,7 +145,7 @@ mod tests {
#[tokio::test]
async fn test_invalid_token_rejected() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let _token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
@@ -237,7 +159,7 @@ mod tests {
#[tokio::test]
async fn test_wrong_purpose_rejected() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
@@ -252,7 +174,7 @@ mod tests {
#[tokio::test]
async fn test_token_format() {
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
(0..50).for_each(|_| {
let token = futures::executor::block_on(create_email_token(
@@ -269,7 +191,7 @@ mod tests {
#[tokio::test]
async fn test_case_insensitive_validation() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
@@ -284,7 +206,7 @@ mod tests {
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:teq").expect("valid DID");
let token = create_email_token(&cache, &did, EmailTokenPurpose::UpdateEmail)
+28 -91
View File
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::cache_keys::{legacy_2fa_challenge_key, legacy_2fa_cooldown_key};
use crate::types::Did;
use crate::util::{generate_token_code, normalize_token_code};
@@ -58,8 +59,8 @@ pub async fn create_challenge(
}
pub async fn clear_challenge(cache: &dyn Cache, did: &Did) {
let _ = cache.delete(&challenge_key(did)).await;
let _ = cache.delete(&cooldown_key(did)).await;
let _ = cache.delete(&legacy_2fa_challenge_key(did)).await;
let _ = cache.delete(&legacy_2fa_cooldown_key(did)).await;
}
async fn validate_challenge_internal(
@@ -71,7 +72,7 @@ async fn validate_challenge_internal(
return Err(ValidationError::CacheUnavailable);
}
let challenge_k = challenge_key(did);
let challenge_k = legacy_2fa_challenge_key(did);
let json = cache
.get(&challenge_k)
@@ -114,19 +115,11 @@ async fn validate_challenge_internal(
}
let _ = cache.delete(&challenge_k).await;
let _ = cache.delete(&cooldown_key(did)).await;
let _ = cache.delete(&legacy_2fa_cooldown_key(did)).await;
Ok(())
}
fn challenge_key(did: &Did) -> String {
format!("legacy_2fa:{}", did)
}
fn cooldown_key(did: &Did) -> String {
format!("legacy_2fa_cooldown:{}", did)
}
fn current_timestamp() -> u64 {
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
}
@@ -226,7 +219,7 @@ async fn create_challenge_code(
return Err(ChallengeError::CacheUnavailable);
}
let cooldown = cooldown_key(did);
let cooldown = legacy_2fa_cooldown_key(did);
if cache.get(&cooldown).await.is_some() {
return Err(ChallengeError::RateLimited);
}
@@ -244,7 +237,7 @@ async fn create_challenge_code(
cache
.set(
&challenge_key(did),
&legacy_2fa_challenge_key(did),
&json,
Duration::from_secs(CHALLENGE_TTL_SECS),
)
@@ -280,67 +273,11 @@ impl From<ValidationError> for Legacy2faFlowError {
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::CacheError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockCache {
data: Mutex<HashMap<String, (String, u64)>>,
}
impl MockCache {
fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
#[async_trait]
impl Cache for MockCache {
async fn get(&self, key: &str) -> Option<String> {
let data = self.data.lock().unwrap();
let now = current_timestamp();
data.get(key)
.filter(|(_, exp)| *exp > now)
.map(|(v, _)| v.clone())
}
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
let mut data = self.data.lock().unwrap();
let expires = current_timestamp() + ttl.as_secs();
data.insert(key.to_string(), (value.to_string(), expires));
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
let mut data = self.data.lock().unwrap();
data.remove(key);
Ok(())
}
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(
&self,
_key: &str,
_value: &[u8],
_ttl: Duration,
) -> Result<(), CacheError> {
Ok(())
}
fn is_available(&self) -> bool {
true
}
}
use tranquil_infra::MemoryCache;
#[tokio::test]
async fn test_create_and_validate_challenge() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -352,7 +289,7 @@ mod tests {
#[tokio::test]
async fn test_challenge_code_format() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -364,7 +301,7 @@ mod tests {
#[tokio::test]
async fn test_case_insensitive_validation() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -375,7 +312,7 @@ mod tests {
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -386,7 +323,7 @@ mod tests {
#[tokio::test]
async fn test_invalid_code_rejected() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let _code = create_challenge(&cache, &did).await.unwrap();
@@ -396,7 +333,7 @@ mod tests {
#[tokio::test]
async fn test_challenge_consumed_on_success() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -410,7 +347,7 @@ mod tests {
#[tokio::test]
async fn test_max_attempts_exceeded() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let _code = create_challenge(&cache, &did).await.unwrap();
@@ -425,7 +362,7 @@ mod tests {
#[tokio::test]
async fn test_rate_limiting() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let _first = create_challenge(&cache, &did).await.unwrap();
@@ -453,7 +390,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_not_required() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -470,7 +407,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_not_required_because_app_password() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: true,
@@ -487,7 +424,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_blocked() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -504,7 +441,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_challenge_sent_totp() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -521,7 +458,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_challenge_sent_email_2fa_enabled() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -538,7 +475,7 @@ mod tests {
#[tokio::test]
async fn test_process_flow_verified() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -557,7 +494,7 @@ mod tests {
#[tokio::test]
async fn test_attempts_persist_across_failures() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
@@ -590,7 +527,7 @@ mod tests {
#[tokio::test]
async fn test_totp_shaped_token_accepted_via_verifier() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:totp1".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -607,7 +544,7 @@ mod tests {
#[tokio::test]
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:totp2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -641,7 +578,7 @@ mod tests {
#[tokio::test]
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:totp3".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -662,7 +599,7 @@ mod tests {
#[tokio::test]
async fn test_backup_code_shaped_token_routes_to_verifier() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:totp4".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
@@ -681,7 +618,7 @@ mod tests {
#[tokio::test]
async fn test_totp_shaped_token_ignored_when_no_totp() {
let cache = MockCache::new();
let cache = MemoryCache::new();
let did = Did::new("did:plc:totp5".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
+9 -8
View File
@@ -43,14 +43,15 @@ pub use scope_verified::{
pub use service::{ServiceTokenClaims, ServiceTokenError, ServiceTokenVerifier, is_service_token};
pub use tranquil_auth::{
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, create_access_token,
create_access_token_hs256, create_access_token_hs256_with_metadata,
create_access_token_with_delegation, create_access_token_with_jti,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token,
create_service_token_hs256, generate_backup_codes, generate_qr_png_base64,
ActClaim, Claims, Header, ScopeDecodeError, ScopeEncodeError, SigningAlgorithm, TokenData,
TokenDecodeError, TokenScope, TokenType, TokenVerifyError, TokenWithMetadata, TotpError,
UnsafeClaims, create_access_token, create_access_token_hs256,
create_access_token_hs256_with_metadata, create_access_token_with_delegation,
create_access_token_with_jti, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
create_refresh_token_hs256_with_metadata, create_refresh_token_with_jti,
create_refresh_token_with_metadata, create_service_token, create_service_token_hs256,
decode_scope, encode_scope, generate_backup_codes, generate_qr_png_base64,
generate_totp_secret, generate_totp_uri, get_algorithm_from_token, get_did_from_token,
get_jti_from_token, hash_backup_code, is_backup_code_format, verify_access_token,
verify_access_token_hs256, verify_backup_code, verify_refresh_token,
+5
View File
@@ -67,6 +67,7 @@ impl WebAuthnConfig {
.get_or_insert_with(AuthenticatorSelectionCriteria::default);
sel.resident_key = Some(ResidentKeyRequirement::Required);
sel.require_resident_key = true;
ccr.public_key.hints = None;
(ccr, state)
})
.map_err(|e| WebauthnError::RegistrationFailed(e.to_string()))
@@ -88,6 +89,10 @@ impl WebAuthnConfig {
) -> Result<(RequestChallengeResponse, SecurityKeyAuthentication), WebauthnError> {
self.webauthn
.start_securitykey_authentication(&credentials)
.map(|(mut rcr, state)| {
rcr.public_key.hints = None;
(rcr, state)
})
.map_err(|e| WebauthnError::AuthenticationFailed(e.to_string()))
}
+3 -1
View File
@@ -1,4 +1,6 @@
pub use tranquil_cache::{Cache, CacheError, DistributedRateLimiter, NoOpCache, create_cache};
pub use tranquil_cache::{
Cache, CacheError, DistributedRateLimiter, NoOpCache, cached_json, create_cache,
};
#[cfg(feature = "valkey")]
pub use tranquil_cache::{RedisRateLimiter, ValkeyCache};
+1 -48
View File
@@ -1,48 +1 @@
use crate::types::{CidLink, Did, Handle, Jti};
pub fn session_key(did: &Did, jti: &Jti) -> String {
format!("auth:session:{}:{}", did, jti)
}
pub fn signing_key_key(did: &Did) -> String {
format!("auth:key:{}", did)
}
pub fn user_status_key(did: &Did) -> String {
format!("auth:status:{}", did)
}
pub fn handle_key(handle: &Handle) -> String {
format!("handle:{}", handle)
}
pub fn reauth_key(did: &Did) -> String {
format!("reauth:{}", did)
}
pub fn plc_doc_key(did: &Did) -> String {
format!("plc:doc:{}", did)
}
pub fn plc_data_key(did: &Did) -> String {
format!("plc:data:{}", did)
}
pub fn email_update_key(did: &Did) -> String {
format!("email_update:{}", did)
}
pub fn scope_ref_key(cid: &CidLink) -> String {
format!("scope_ref:{}", cid)
}
pub fn auto_verify_sent_key(did: &Did) -> String {
format!("auto_verify_sent:{}", did)
}
pub fn permission_set_key(nsid: &tranquil_types::Nsid, aud: Option<&str>) -> String {
match aud {
Some(a) => format!("permset:{}:{}", nsid, a),
None => format!("permset:{}", nsid),
}
}
pub use tranquil_cache::cache_keys::*;
+25 -18
View File
@@ -5,14 +5,24 @@ pub use roles::{
CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts,
};
pub use scopes::{
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
ValidatedDelegationScope, grant_covers, intersect_scopes,
EDITOR_FULL_SCOPES, GrantCoverage, InvalidDelegationScopeError, OWNER_FULL_SCOPES,
SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, grant_coverage, intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
use crate::did::DidResolutionError;
use crate::state::AppState;
use crate::types::{Did, Handle};
use tranquil_types::did_doc::{PdsEndpointError, extract_handle, extract_pds_endpoint};
use tranquil_types::{InvalidHttpUrl, PdsUrl};
#[derive(Debug, thiserror::Error)]
pub enum IdentityResolutionError {
#[error(transparent)]
DidResolution(#[from] DidResolutionError),
#[error("remote PDS endpoint is unusable: {0}")]
PdsEndpoint(InvalidHttpUrl),
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
@@ -21,14 +31,14 @@ pub struct ResolvedIdentity {
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<Handle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pds_url: Option<String>,
pub pds_url: Option<PdsUrl>,
pub is_local: bool,
}
pub async fn resolve_identity(
state: &AppState,
did: &Did,
) -> Result<ResolvedIdentity, DidResolutionError> {
) -> Result<ResolvedIdentity, IdentityResolutionError> {
let is_local = state
.repos
.user
@@ -38,26 +48,23 @@ pub async fn resolve_identity(
.flatten()
.is_some();
let did_doc = state.did_resolver.resolve_did(did).await?;
let did_doc = state.did_resolver.fetch_did_document(did).await?;
let pds_url = did_doc.services.iter().find_map(|svc| {
if (svc.id == "#atproto_pds" || svc.id.ends_with("#atproto_pds"))
&& svc.service_type == "AtprotoPersonalDataServer"
{
Some(svc.service_endpoint.clone())
} else {
let pds_url = match (extract_pds_endpoint(&did_doc), is_local) {
(Ok(url), _) => Some(url),
(Err(PdsEndpointError::Missing), _) => None,
(Err(PdsEndpointError::Invalid(e)), true) => {
tracing::debug!(did = %did, error = %e, "local account has an unusable PDS endpoint");
None
}
});
let handle = did_doc
.also_known_as
.iter()
.find_map(|alias| alias.strip_prefix("at://"))
.and_then(|s| Handle::new(s).ok());
(Err(PdsEndpointError::Invalid(e)), false) => {
return Err(IdentityResolutionError::PdsEndpoint(e));
}
};
Ok(ResolvedIdentity {
did: did.clone(),
handle,
handle: extract_handle(&did_doc),
pds_url,
is_local,
})
+88 -49
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use std::collections::BTreeSet;
use tranquil_scopes::{covers, parse_scope};
use tranquil_scopes::{Coverage, ParsedScope, coverage, parse_scope};
pub use tranquil_db_traits::{
DbScope as ValidatedDelegationScope, InvalidScopeError as InvalidDelegationScopeError,
@@ -46,35 +46,51 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
},
];
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
granted.split_whitespace().map(parse_scope).collect();
let mut scopes: Vec<&str> = requested_set
.iter()
.filter(|requested_scope| {
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_parsed)
})
.copied()
.chain(requested_set.contains("atproto").then_some("atproto"))
.collect();
scopes.sort();
scopes.join(" ")
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantCoverage {
Full,
Narrowed(String),
Withheld,
}
pub fn grant_covers(granted: &str, scope: &str) -> bool {
fn scope_coverage(granted: &[ParsedScope], scope: &str) -> GrantCoverage {
if scope == "atproto" {
return true;
return GrantCoverage::Full;
}
match coverage(granted, &parse_scope(scope)) {
Coverage::Full => GrantCoverage::Full,
Coverage::Narrowed(ParsedScope::Repo(repo)) => {
GrantCoverage::Narrowed(repo.to_scope_string())
}
Coverage::Narrowed(_) => GrantCoverage::Full,
Coverage::Withheld => GrantCoverage::Withheld,
}
let granted_parsed: Vec<tranquil_scopes::ParsedScope> =
granted.split_whitespace().map(parse_scope).collect();
any_granted_covers(scope, &granted_parsed)
}
fn any_granted_covers(requested: &str, granted: &[tranquil_scopes::ParsedScope]) -> bool {
let requested_parsed = parse_scope(requested);
granted.iter().any(|g| covers(g, &requested_parsed))
pub fn grant_coverage(granted: &str, scope: &str) -> GrantCoverage {
scope_coverage(&parse_grant(granted), scope)
}
fn parse_grant(granted: &str) -> Vec<ParsedScope> {
granted.split_whitespace().map(parse_scope).collect()
}
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
let granted_parsed = parse_grant(granted);
let scopes: BTreeSet<String> = requested
.split_whitespace()
.filter_map(
|requested_scope| match scope_coverage(&granted_parsed, requested_scope) {
GrantCoverage::Full => Some(requested_scope.to_string()),
GrantCoverage::Narrowed(narrowed) => Some(narrowed),
GrantCoverage::Withheld => None,
},
)
.collect();
scopes.into_iter().collect::<Vec<String>>().join(" ")
}
#[cfg(test)]
@@ -220,12 +236,33 @@ mod tests {
}
#[test]
fn test_intersect_partial_action_grant_drops_actionless_request() {
fn test_intersect_partial_action_grant_narrows_actionless_request() {
let result = intersect_scopes(
"repo:app.bsky.feed.post",
"repo:*?action=create&action=delete",
);
assert_eq!(result, "");
assert_eq!(
result,
"repo:app.bsky.feed.post?action=create&action=delete"
);
}
#[test]
fn test_intersect_keeps_collapsed_request_under_split_action_grant() {
assert_eq!(
intersect_scopes(
"repo:io.atcr.manifest?action=create&action=delete",
EDITOR_FULL_SCOPES
),
"repo:io.atcr.manifest?action=create&action=delete"
);
assert_eq!(
intersect_scopes(
"repo:io.atcr.manifest?action=create&action=delete",
"repo:*?action=create"
),
"repo:io.atcr.manifest?action=create"
);
}
#[test]
@@ -262,33 +299,35 @@ mod tests {
}
#[test]
fn test_grant_covers_matches_intersection() {
fn test_grant_coverage_full_and_withheld() {
let granted = "atproto repo:* blob:*/* account:*?action=manage";
let intersected = intersect_scopes(
"repo:app.bsky.feed.post?action=create identity:* account:*?action=manage",
granted,
);
assert!(grant_covers(
granted,
"repo:app.bsky.feed.post?action=create"
));
assert!(grant_covers(granted, "account:*?action=manage"));
assert!(!grant_covers(granted, "identity:*"));
assert_eq!(grant_coverage(granted, "atproto"), GrantCoverage::Full);
assert_eq!(
grant_covers(granted, "identity:*"),
intersected.contains("identity")
grant_coverage(granted, "repo:app.bsky.feed.post?action=create"),
GrantCoverage::Full
);
assert_eq!(
grant_coverage(granted, "identity:*"),
GrantCoverage::Withheld
);
assert_eq!(grant_coverage("", "identity:*"), GrantCoverage::Withheld);
}
#[test]
fn test_grant_covers_atproto_always_true() {
assert!(grant_covers("", "atproto"));
assert!(grant_covers("repo:*", "atproto"));
}
#[test]
fn test_grant_covers_empty_grant_covers_nothing_else() {
assert!(!grant_covers("", "repo:app.bsky.feed.post?action=create"));
assert!(!grant_covers("", "identity:*"));
fn test_grant_coverage_narrowed_when_grant_is_a_strict_action_subset() {
assert_eq!(
grant_coverage(
EDITOR_FULL_SCOPES,
"repo:io.atcr.manifest?action=create&action=delete"
),
GrantCoverage::Full
);
assert_eq!(
grant_coverage(
"atproto repo:*?action=create blob:*/*",
"repo:io.atcr.manifest?action=create&action=delete"
),
GrantCoverage::Narrowed("repo:io.atcr.manifest?action=create".to_string())
);
}
}
+69 -197
View File
@@ -1,10 +1,9 @@
use crate::cache::Cache;
use crate::types::Did;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use std::time::Duration;
use tracing::{debug, info, warn};
#[derive(Debug, thiserror::Error)]
@@ -13,6 +12,8 @@ pub enum DidResolutionError {
UnsupportedDidMethod(String),
#[error("Invalid did:web format")]
InvalidDidWeb,
#[error("did:web host {0} is outside the allowed host reach")]
DidWebHostRejected(String),
#[error("HTTP request failed: {0}")]
HttpFailed(String),
#[error("Invalid DID document: {0}")]
@@ -53,43 +54,50 @@ pub struct DidService {
pub struct ResolvedService {
pub url: String,
pub did: Did,
pub service_id: String,
}
type TimedCache<T> = RwLock<HashMap<Box<str>, (Instant, Arc<T>)>>;
pub struct DidResolver {
did_doc_cache: TimedCache<serde_json::Value>,
parsed_did_doc_cache: TimedCache<DidDocument>,
service_cache: TimedCache<ResolvedService>,
cache: Arc<dyn Cache>,
client: Client,
cache_ttl: Duration,
plc_directory_url: String,
}
impl DidResolver {
pub fn new() -> Self {
pub fn new(cache: Arc<dyn Cache>) -> Self {
let cfg = tranquil_config::get();
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
let plc_directory_url = cfg.plc.directory_url.clone();
let client = Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(10)
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.build()
.unwrap_or_else(|_| Client::new());
.expect("failed to build DID resolver HTTP client");
info!("DID resolver initialized");
Self {
did_doc_cache: RwLock::new(HashMap::new()),
parsed_did_doc_cache: RwLock::new(HashMap::new()),
service_cache: RwLock::new(HashMap::new()),
cache,
client,
cache_ttl: Duration::from_secs(cache_ttl_secs),
plc_directory_url,
cache_ttl: Duration::from_secs(cfg.plc.did_cache_ttl_secs),
plc_directory_url: cfg.plc.directory_url.clone(),
}
}
fn doc_cache_key(did: &Did) -> Result<String, DidResolutionError> {
match (did.is_plc(), did.is_web()) {
(true, _) => Ok(crate::cache_keys::plc_doc_key(did)),
(_, true) => Ok(crate::cache_keys::did_web_doc_key(did)),
_ => {
warn!("Unsupported DID method: {}", did);
Err(DidResolutionError::UnsupportedDidMethod(did.to_string()))
}
}
}
@@ -97,175 +105,50 @@ impl DidResolver {
&self,
did: &Did,
service_id: &str,
) -> Result<Arc<ResolvedService>, ServiceResolutionError> {
{
let cache = self.service_cache.read().await;
if let Some(cached) = cache.get(&*format!("{did}#{service_id}"))
&& cached.0.elapsed() < self.cache_ttl
{
return Ok(cached.1.clone());
}
}
) -> Result<ResolvedService, ServiceResolutionError> {
let did_doc = self.resolve_did(did).await?;
let Some(service) = did_doc
let suffix = format!("#{service_id}");
did_doc
.services
.iter()
.find(|s| s.id.ends_with(&format!("#{service_id}")))
else {
return Err(ServiceResolutionError::ServiceIdNotFound(service_id.into()));
};
let resolved = Arc::new(ResolvedService {
url: service.service_endpoint.clone(),
did: did.clone(),
service_id: service_id.into(),
});
{
let mut cache = self.service_cache.write().await;
cache.insert(
format!("{did}#{service_id}").into(),
(Instant::now(), resolved.clone()),
);
}
Ok(resolved)
.find(|s| s.id.ends_with(&suffix))
.map(|service| ResolvedService {
url: service.service_endpoint.clone(),
did: did.clone(),
})
.ok_or_else(|| ServiceResolutionError::ServiceIdNotFound(service_id.into()))
}
pub async fn resolve_did(&self, did: &Did) -> Result<Arc<DidDocument>, DidResolutionError> {
{
let cache = self.parsed_did_doc_cache.read().await;
if let Some(cached) = cache.get(did.as_str())
&& cached.0.elapsed() < self.cache_ttl
{
return Ok(cached.1.clone());
}
}
let resolved = Arc::new(self.resolve_did_uncached(did).await?);
{
let mut cache = self.parsed_did_doc_cache.write().await;
cache.insert(did.as_str().into(), (Instant::now(), resolved.clone()));
}
Ok(resolved)
pub async fn resolve_did(&self, did: &Did) -> Result<DidDocument, DidResolutionError> {
self.cached_did_document(did).await
}
pub async fn refresh_did(&self, did: &Did) -> Result<Arc<DidDocument>, DidResolutionError> {
{
let mut cache = self.parsed_did_doc_cache.write().await;
cache.remove(did.as_str());
let mut cache = self.service_cache.write().await;
cache.retain(|k, _| !k.starts_with(did.as_str()));
}
pub async fn refresh_did(&self, did: &Did) -> Result<DidDocument, DidResolutionError> {
let _ = self.cache.delete(&Self::doc_cache_key(did)?).await;
self.resolve_did(did).await
}
async fn resolve_did_uncached(&self, did: &Did) -> Result<DidDocument, DidResolutionError> {
if did.is_web() {
self.resolve_did_web(did).await
} else if did.is_plc() {
self.resolve_did_plc(did).await
} else {
warn!("Unsupported DID method: {}", did);
Err(DidResolutionError::UnsupportedDidMethod(did.to_string()))
}
}
async fn resolve_did_web(&self, did: &Did) -> Result<DidDocument, DidResolutionError> {
let url = build_did_web_url(did)?;
debug!("Resolving did:web {} via {}", did, url);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if !resp.status().is_success() {
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<DidDocument>()
.await
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
async fn resolve_did_plc(&self, did: &Did) -> Result<DidDocument, DidResolutionError> {
let url = format!(
"{}/{}",
self.plc_directory_url,
urlencoding::encode(did.as_str())
);
debug!("Resolving did:plc {} via {}", did, url);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(DidResolutionError::NotFound);
}
if !resp.status().is_success() {
return Err(DidResolutionError::HttpFailed(format!(
"HTTP {}",
resp.status()
)));
}
resp.json::<DidDocument>()
.await
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
pub async fn fetch_did_document(
&self,
did: &Did,
) -> Result<Arc<serde_json::Value>, DidResolutionError> {
{
let cache = self.did_doc_cache.read().await;
if let Some(cached) = cache.get(did.as_str())
&& cached.0.elapsed() < self.cache_ttl
{
return Ok(cached.1.clone());
}
}
let resolved = Arc::new(self.fetch_did_document_uncached(did).await?);
{
let mut cache = self.did_doc_cache.write().await;
cache.insert(did.as_str().into(), (Instant::now(), resolved.clone()));
}
Ok(resolved)
) -> Result<serde_json::Value, DidResolutionError> {
self.cached_did_document(did).await
}
// TODO: make cached version
async fn fetch_did_document_uncached(
async fn cached_did_document<T: serde::de::DeserializeOwned>(
&self,
did: &Did,
) -> Result<serde_json::Value, DidResolutionError> {
if did.is_web() {
self.fetch_did_document_web(did).await
} else if did.is_plc() {
self.fetch_did_document_plc(did).await
} else {
warn!("Unsupported DID method: {}", did);
Err(DidResolutionError::UnsupportedDidMethod(did.to_string()))
}
) -> Result<T, DidResolutionError> {
let cache_key = Self::doc_cache_key(did)?;
let doc =
crate::cache::cached_json(self.cache.as_ref(), &cache_key, self.cache_ttl, || async {
match did.is_plc() {
true => self.fetch_did_document_plc(did).await,
false => self.fetch_did_document_web(did).await,
}
})
.await?;
serde_json::from_value(doc).map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
async fn fetch_did_document_web(
@@ -274,6 +157,8 @@ impl DidResolver {
) -> Result<serde_json::Value, DidResolutionError> {
let url = build_did_web_url(did)?;
debug!("Resolving did:web {} via {}", did, url);
let resp = self
.client
.get(&url)
@@ -303,6 +188,8 @@ impl DidResolver {
urlencoding::encode(did.as_str())
);
debug!("Resolving did:plc {} via {}", did, url);
let resp = self
.client
.get(&url)
@@ -325,21 +212,6 @@ impl DidResolver {
.await
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
}
pub async fn invalidate_cache(&self, did: &Did) {
let mut doc_cache = self.parsed_did_doc_cache.write().await;
doc_cache.remove(did.as_str());
}
}
impl Default for DidResolver {
fn default() -> Self {
Self::new()
}
}
pub fn create_did_resolver() -> Arc<DidResolver> {
Arc::new(DidResolver::new())
}
fn build_did_web_url(did: &Did) -> Result<String, DidResolutionError> {
@@ -372,18 +244,18 @@ fn build_did_web_url(did: &Did) -> Result<String, DidResolutionError> {
}
};
let scheme =
if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') {
"http"
} else {
"https"
};
let url = if path.is_empty() {
format!("{}://{}/.well-known/did.json", scheme, host)
let https = if path.is_empty() {
format!("https://{}/.well-known/did.json", host)
} else {
format!("{}://{}{}/did.json", scheme, host, path)
format!("https://{}{}/did.json", host, path)
};
Ok(url)
let mut url = reqwest::Url::parse(&https).map_err(|_| DidResolutionError::InvalidDidWeb)?;
if tranquil_types::url_reach(&url) == Some(tranquil_types::HostReach::Loopback) {
let _ = url.set_scheme("http");
}
match tranquil_types::url_reach_permits(&url, tranquil_types::ReachPolicy::DEBUG_LOOPBACK) {
true => Ok(url.to_string()),
false => Err(DidResolutionError::DidWebHostRejected(host)),
}
}
@@ -1,6 +1,9 @@
use std::collections::HashSet;
use std::sync::LazyLock;
// TODO: make all of this configurable.
// PDS implementation should not impose any reserved domains imo.
// some of these are even bad to have as defaults let alone non-configurables
const ATP_SPECIFIC: &[&str] = &[
"at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky",
"bluesky", "handle",
+15 -12
View File
@@ -35,7 +35,7 @@ use serde_json::json;
use state::AppState;
use tower::ServiceBuilder;
use tower_http::{
cors::{Any, CorsLayer},
cors::{AllowHeaders, Any, CorsLayer},
services::{ServeDir, ServeFile},
};
pub use tranquil_db_traits::AccountStatus;
@@ -106,17 +106,20 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
http::header::USER_AGENT,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
util::HEADER_X_BSKY_TOPICS,
])
.allow_headers(AllowHeaders::list(
[
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
http::header::USER_AGENT,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
]
.into_iter()
.chain(util::CORS_BSKY_ALLOW_HEADERS),
))
.expose_headers([
http::header::WWW_AUTHENTICATE,
util::HEADER_DPOP_NONCE,
+70 -65
View File
@@ -10,10 +10,12 @@ use tranquil_oauth::{
AuthorizationServerMetadata, ClientMetadata, compute_es256_jkt, compute_pkce_challenge,
create_dpop_proof,
};
use tranquil_types::{AuthorizationCode, ClientId, Did};
use tranquil_types::{AuthorizationCode, ClientId, CrossPdsState, Did, Issuer, PdsUrl};
use crate::cache::Cache;
const SERVER_METADATA_TTL: Duration = Duration::from_secs(300);
#[derive(Error, Debug)]
pub enum CrossPdsError {
#[error("failed to fetch OAuth metadata: {0}")]
@@ -32,11 +34,11 @@ pub enum CrossPdsError {
pub struct CrossPdsAuthState {
pub original_request_uri: String,
pub controller_did: Did,
pub controller_pds_url: String,
pub controller_pds_url: PdsUrl,
pub code_verifier: String,
pub dpop_private_key_der: String,
pub delegated_did: Did,
pub expected_issuer: Option<String>,
pub expected_issuer: Option<Issuer>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -70,17 +72,23 @@ impl CrossPdsOAuthClient {
let http = Client::builder()
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::GlobalOnly,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::GlobalOnly,
))
.build()
.unwrap_or_else(|_| Client::new());
.expect("failed to build cross-PDS OAuth HTTP client");
Self { http, cache }
}
pub async fn store_auth_state(
&self,
state_key: &str,
state_key: &CrossPdsState,
auth_state: &CrossPdsAuthState,
) -> Result<(), CrossPdsError> {
let cache_key = format!("cross_pds_state:{}", state_key);
let cache_key = crate::cache_keys::cross_pds_state_key(state_key);
let json_bytes = serde_json::to_vec(auth_state)
.map_err(|e| CrossPdsError::ParFailed(format!("serialize auth state: {}", e)))?;
let encrypted = crate::config::encrypt_key(&json_bytes)
@@ -93,9 +101,9 @@ impl CrossPdsOAuthClient {
pub async fn retrieve_auth_state(
&self,
state_key: &str,
state_key: &CrossPdsState,
) -> Result<CrossPdsAuthState, CrossPdsError> {
let cache_key = format!("cross_pds_state:{}", state_key);
let cache_key = crate::cache_keys::cross_pds_state_key(state_key);
let encrypted_bytes = self.cache.get_bytes(&cache_key).await.ok_or_else(|| {
CrossPdsError::TokenExchangeFailed("auth state expired or not found".into())
})?;
@@ -110,13 +118,11 @@ impl CrossPdsOAuthClient {
})
}
pub async fn check_remote_is_delegated(&self, pds_url: &str, did: &Did) -> Option<bool> {
let url = format!(
"{}/oauth/security-status?identifier={}",
pds_url.trim_end_matches('/'),
urlencoding::encode(did.as_str())
);
let resp = self.http.get(&url).send().await.ok()?;
pub async fn check_remote_is_delegated(&self, pds_url: &PdsUrl, did: &Did) -> Option<bool> {
let mut url = pds_url.endpoint("oauth/security-status");
url.query_pairs_mut()
.append_pair("identifier", did.as_str());
let resp = self.http.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
@@ -176,24 +182,12 @@ impl CrossPdsOAuthClient {
Ok(resp)
}
fn require_https(url: &str, label: &str) -> Result<(), CrossPdsError> {
if !url.starts_with("https://") {
return Err(CrossPdsError::MetadataFetch(format!(
"{} must use HTTPS, got: {}",
label, url
)));
}
Ok(())
}
async fn resolve_authorization_server(&self, pds_url: &str) -> Result<String, CrossPdsError> {
Self::require_https(pds_url, "PDS URL")?;
let resource_url = format!(
"{}/.well-known/oauth-protected-resource",
pds_url.trim_end_matches('/')
);
if let Ok(resp) = self.http.get(&resource_url).send().await
async fn resolve_authorization_server(
&self,
pds_url: &PdsUrl,
) -> Result<Issuer, CrossPdsError> {
let resource_url = pds_url.endpoint(".well-known/oauth-protected-resource");
if let Ok(resp) = self.http.get(resource_url).send().await
&& resp.status().is_success()
{
#[derive(Deserialize)]
@@ -203,30 +197,36 @@ impl CrossPdsOAuthClient {
if let Ok(pr) = resp.json::<ProtectedResource>().await
&& let Some(server) = pr.authorization_servers.and_then(|s| s.into_iter().next())
{
Self::require_https(&server, "Authorization server")?;
return Ok(server);
return Issuer::new(server)
.map_err(|e| CrossPdsError::MetadataFetch(e.to_string()));
}
}
Ok(pds_url.trim_end_matches('/').to_string())
Issuer::new(pds_url.as_str()).map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))
}
pub async fn fetch_server_metadata(
&self,
pds_url: &str,
pds_url: &PdsUrl,
) -> Result<AuthorizationServerMetadata, CrossPdsError> {
let cache_key = format!("cross_pds_oauth_meta:{}", pds_url);
if let Some(cached) = self.cache.get(&cache_key).await
&& let Ok(meta) = serde_json::from_str(&cached)
{
return Ok(meta);
}
crate::cache::cached_json(
self.cache.as_ref(),
&crate::cache_keys::cross_pds_oauth_meta_key(pds_url),
SERVER_METADATA_TTL,
|| self.fetch_verified_server_metadata(pds_url),
)
.await
}
async fn fetch_verified_server_metadata(
&self,
pds_url: &PdsUrl,
) -> Result<AuthorizationServerMetadata, CrossPdsError> {
let auth_server = self.resolve_authorization_server(pds_url).await?;
let url = format!("{}/.well-known/oauth-authorization-server", auth_server);
let url = auth_server.endpoint(".well-known/oauth-authorization-server");
let resp = self
.http
.get(&url)
.get(url.clone())
.send()
.await
.map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?;
@@ -244,11 +244,11 @@ impl CrossPdsOAuthClient {
.await
.map_err(|e| CrossPdsError::MetadataFetch(e.to_string()))?;
if let Ok(json_str) = serde_json::to_string(&meta) {
let _ = self
.cache
.set(&cache_key, &json_str, Duration::from_secs(300))
.await;
if meta.issuer != auth_server {
return Err(CrossPdsError::MetadataFetch(format!(
"issuer mismatch: {} serves metadata for {}",
auth_server, meta.issuer
)));
}
Ok(meta)
@@ -256,22 +256,22 @@ impl CrossPdsOAuthClient {
pub async fn initiate_par(
&self,
pds_url: &str,
pds_url: &PdsUrl,
urls: &DelegationOAuthUrls,
login_hint: Option<&str>,
original_request_uri: &str,
controller_did: &Did,
delegated_did: &Did,
) -> Result<(ParResult, CrossPdsAuthState, String), CrossPdsError> {
) -> Result<(ParResult, CrossPdsAuthState, CrossPdsState), CrossPdsError> {
let meta = self.fetch_server_metadata(pds_url).await?;
let par_endpoint = meta
.pushed_authorization_request_endpoint
.as_deref()
.as_ref()
.ok_or(CrossPdsError::NoParEndpoint)?;
let code_verifier = crate::util::generate_random_token();
let code_challenge = compute_pkce_challenge(&code_verifier);
let state = crate::util::generate_random_token();
let state = CrossPdsState::new(crate::util::generate_random_token());
let signing_key = SigningKey::random(&mut OsRng);
let dpop_key_der = URL_SAFE_NO_PAD.encode(signing_key.to_bytes());
@@ -284,7 +284,7 @@ impl CrossPdsOAuthClient {
("client_id", urls.client_id.to_string()),
("redirect_uri", urls.redirect_uri.clone()),
("scope", "atproto".to_string()),
("state", state.clone()),
("state", state.to_string()),
("code_challenge", code_challenge),
("code_challenge_method", "S256".to_string()),
("dpop_jkt", dpop_jkt),
@@ -294,7 +294,7 @@ impl CrossPdsOAuthClient {
}
let resp = self
.send_with_dpop_retry(&signing_key, "POST", par_endpoint, &params, None)
.send_with_dpop_retry(&signing_key, "POST", par_endpoint.as_str(), &params, None)
.await
.map_err(|e| CrossPdsError::ParFailed(e.to_string()))?;
@@ -313,17 +313,16 @@ impl CrossPdsOAuthClient {
.await
.map_err(|e| CrossPdsError::ParFailed(e.to_string()))?;
let authorize_url = format!(
"{}?request_uri={}&client_id={}",
meta.authorization_endpoint,
urlencoding::encode(&par_resp.request_uri),
urlencoding::encode(&urls.client_id)
);
let mut authorize_url = meta.authorization_endpoint.url().clone();
authorize_url
.query_pairs_mut()
.append_pair("request_uri", &par_resp.request_uri)
.append_pair("client_id", &urls.client_id);
let auth_state = CrossPdsAuthState {
original_request_uri: original_request_uri.to_string(),
controller_did: controller_did.clone(),
controller_pds_url: pds_url.to_string(),
controller_pds_url: pds_url.clone(),
code_verifier,
dpop_private_key_der: dpop_key_der,
delegated_did: delegated_did.clone(),
@@ -333,7 +332,7 @@ impl CrossPdsOAuthClient {
Ok((
ParResult {
request_uri: par_resp.request_uri,
authorize_url,
authorize_url: authorize_url.into(),
},
auth_state,
state,
@@ -366,7 +365,13 @@ impl CrossPdsOAuthClient {
];
let resp = self
.send_with_dpop_retry(&signing_key, "POST", &meta.token_endpoint, &params, None)
.send_with_dpop_retry(
&signing_key,
"POST",
meta.token_endpoint.as_str(),
&params,
None,
)
.await
.map_err(CrossPdsError::TokenExchangeFailed)?;
@@ -137,39 +137,12 @@ fn map_err(e: &ScopeExpansionError) -> ResolveFailure {
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::{Cache, CacheError};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tranquil_infra::MemoryCache;
#[derive(Default)]
struct MapCache(Mutex<HashMap<String, String>>);
const SEED_TTL: Duration = Duration::from_secs(3600);
#[async_trait::async_trait]
impl Cache for MapCache {
async fn get(&self, key: &str) -> Option<String> {
self.0.lock().unwrap().get(key).cloned()
}
async fn set(&self, key: &str, value: &str, _ttl: Duration) -> Result<(), CacheError> {
self.0
.lock()
.unwrap()
.insert(key.to_string(), value.to_string());
Ok(())
}
async fn delete(&self, key: &str) -> Result<(), CacheError> {
self.0.lock().unwrap().remove(key);
Ok(())
}
async fn get_bytes(&self, _key: &str) -> Option<Vec<u8>> {
None
}
async fn set_bytes(&self, _k: &str, _v: &[u8], _t: Duration) -> Result<(), CacheError> {
Ok(())
}
}
fn seed_at(cache: &MapCache, nsid: &str, scope: &str, refreshed_at: i64) {
async fn seed_at(cache: &MemoryCache, nsid: &str, scope: &str, refreshed_at: i64) {
let key =
crate::cache_keys::permission_set_key(&tranquil_types::Nsid::new(nsid).unwrap(), None);
let val = serde_json::to_string(&CachedPermissionSet {
@@ -179,21 +152,22 @@ mod tests {
refreshed_at,
})
.unwrap();
cache.0.lock().unwrap().insert(key, val);
let _ = cache.set(&key, &val, SEED_TTL).await;
}
fn seed(cache: &MapCache, nsid: &str, scope: &str) {
seed_at(cache, nsid, scope, now_secs());
async fn seed(cache: &MemoryCache, nsid: &str, scope: &str) {
seed_at(cache, nsid, scope, now_secs()).await;
}
#[tokio::test]
async fn cache_hit_expands_without_network() {
let cache = MapCache::default();
let cache = MemoryCache::new();
seed(
&cache,
"io.atcr.authFullApp",
"repo:io.atcr.manifest?action=create identity:*",
);
)
.await;
let out = expand_scopes(&cache, "atproto include:io.atcr.authFullApp").await;
assert!(out.failures.is_empty());
assert_eq!(out.passthrough, vec!["atproto".to_string()]);
@@ -208,13 +182,14 @@ mod tests {
#[tokio::test]
async fn stale_entry_is_served_when_refresh_fails() {
let cache = MapCache::default();
let cache = MemoryCache::new();
seed_at(
&cache,
"nonexistent.fake.permissionSet",
"repo:nonexistent.fake.record?action=create",
now_secs() - STALE_AFTER_SECS - 1,
);
)
.await;
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert!(
out.failures.is_empty(),
@@ -230,7 +205,7 @@ mod tests {
#[tokio::test]
async fn entry_without_refreshed_at_is_treated_as_stale_but_usable() {
let cache = MapCache::default();
let cache = MemoryCache::new();
let key = crate::cache_keys::permission_set_key(
&tranquil_types::Nsid::new("nonexistent.fake.permissionSet").unwrap(),
None,
@@ -238,7 +213,7 @@ mod tests {
// Shape written before `refreshed_at` existed.
let legacy =
r#"{"scope":"repo:nonexistent.fake.record?action=create","title":null,"detail":null}"#;
cache.0.lock().unwrap().insert(key, legacy.to_string());
let _ = cache.set(&key, legacy, SEED_TTL).await;
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert!(out.failures.is_empty());
assert_eq!(out.sets.len(), 1);
@@ -246,7 +221,7 @@ mod tests {
#[tokio::test]
async fn passthrough_scopes_untouched() {
let cache = MapCache::default();
let cache = MemoryCache::new();
let out = expand_scopes(&cache, "atproto repo:app.bsky.feed.post?action=create").await;
assert!(out.failures.is_empty());
assert!(out.sets.is_empty());
@@ -255,7 +230,7 @@ mod tests {
#[tokio::test]
async fn cache_miss_unresolvable_is_a_failure() {
let cache = MapCache::default();
let cache = MemoryCache::new();
let out = expand_scopes(&cache, "include:nonexistent.fake.permissionSet").await;
assert_eq!(out.sets.len(), 0);
assert_eq!(out.failures.len(), 1);
+3 -1
View File
@@ -164,7 +164,9 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
let scope = payload
.get("scope")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
.map(crate::auth::decode_scope)
.transpose()
.map_err(|_| OAuthError::InvalidToken("Invalid scope claim encoding".to_string()))?;
let controller_did = payload
.get("act")
.and_then(|a| a.get("sub"))
+42 -93
View File
@@ -165,12 +165,11 @@ impl PlcOpOrTombstone {
}
}
const PLC_CACHE_TTL_SECS: u64 = 300;
pub struct PlcClient {
base_url: String,
client: Client,
cache: Option<Arc<dyn Cache>>,
cache_ttl: Duration,
}
impl PlcClient {
@@ -193,12 +192,19 @@ impl PlcClient {
.connect_timeout(Duration::from_secs(connect_timeout_secs))
.pool_max_idle_per_host(5)
.pool_idle_timeout(Duration::from_secs(90))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::DEBUG_LOOPBACK,
))
.build()
.unwrap_or_else(|_| Client::new());
.expect("failed to build PLC directory HTTP client");
Self {
base_url,
client,
cache,
cache_ttl: Duration::from_secs(cfg.map_or(300, |c| c.plc.did_cache_ttl_secs)),
}
}
@@ -206,15 +212,7 @@ impl PlcClient {
urlencoding::encode(did.as_str()).to_string()
}
pub async fn get_document(&self, did: &Did) -> Result<Value, PlcError> {
let cache_key = crate::cache_keys::plc_doc_key(did);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key).await
&& let Ok(value) = serde_json::from_str(&cached)
{
return Ok(value);
}
let url = format!("{}/{}", self.base_url, Self::encode_did(did));
async fn fetch_json<T: serde::de::DeserializeOwned>(&self, url: String) -> Result<T, PlcError> {
let response = self.client.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PlcError::NotFound);
@@ -227,101 +225,52 @@ impl PlcClient {
status, body
)));
}
let value: Value = response
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))?;
if let Some(ref cache) = self.cache
&& let Ok(json_str) = serde_json::to_string(&value)
{
let _ = cache
.set(
&cache_key,
&json_str,
Duration::from_secs(PLC_CACHE_TTL_SECS),
)
.await;
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
}
async fn cached_fetch(&self, cache_key: &str, url: String) -> Result<Value, PlcError> {
match &self.cache {
Some(cache) => {
crate::cache::cached_json(cache.as_ref(), cache_key, self.cache_ttl, || {
self.fetch_json(url)
})
.await
}
None => self.fetch_json(url).await,
}
Ok(value)
}
pub async fn get_document(&self, did: &Did) -> Result<Value, PlcError> {
let url = format!("{}/{}", self.base_url, Self::encode_did(did));
self.cached_fetch(&crate::cache_keys::plc_doc_key(did), url)
.await
}
pub async fn get_document_data(&self, did: &Did) -> Result<Value, PlcError> {
let cache_key = crate::cache_keys::plc_data_key(did);
if let Some(ref cache) = self.cache
&& let Some(cached) = cache.get(&cache_key).await
&& let Ok(value) = serde_json::from_str(&cached)
{
return Ok(value);
}
let url = format!("{}/{}/data", self.base_url, Self::encode_did(did));
let response = self.client.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PlcError::NotFound);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(PlcError::InvalidResponse(format!(
"HTTP {}: {}",
status, body
)));
}
let value: Value = response
.json()
self.cached_fetch(&crate::cache_keys::plc_data_key(did), url)
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))?;
if let Some(ref cache) = self.cache
&& let Ok(json_str) = serde_json::to_string(&value)
{
let _ = cache
.set(
&cache_key,
&json_str,
Duration::from_secs(PLC_CACHE_TTL_SECS),
)
.await;
}
Ok(value)
}
pub async fn get_last_op(&self, did: &Did) -> Result<PlcOpOrTombstone, PlcError> {
let url = format!("{}/{}/log/last", self.base_url, Self::encode_did(did));
let response = self.client.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PlcError::NotFound);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(PlcError::InvalidResponse(format!(
"HTTP {}: {}",
status, body
)));
}
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
self.fetch_json(format!(
"{}/{}/log/last",
self.base_url,
Self::encode_did(did)
))
.await
}
pub async fn get_audit_log(&self, did: &Did) -> Result<Vec<Value>, PlcError> {
let url = format!("{}/{}/log/audit", self.base_url, Self::encode_did(did));
let response = self.client.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(PlcError::NotFound);
}
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(PlcError::InvalidResponse(format!(
"HTTP {}: {}",
status, body
)));
}
response
.json()
.await
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
self.fetch_json(format!(
"{}/{}/log/audit",
self.base_url,
Self::encode_did(did)
))
.await
}
pub async fn send_operation(&self, did: &Did, operation: &Value) -> Result<(), PlcError> {
+4
View File
@@ -115,6 +115,10 @@ pub fn extract_blob_cids(record: &Value) -> Vec<crate::types::CidLink> {
use crate::types::AtUri;
use tranquil_db_traits::{Backlink, BacklinkPath};
// TODO: it really really should not be necessary to extract backlinks and store those.
// especially not in a way that isnt generic.
// figure out what the fuck is going on here
// (lewis do you remember why you did this???)
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
let record_type = record
.get("$type")
+156 -113
View File
@@ -4,15 +4,23 @@ use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, jwk:
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::sync::{OnceCell, RwLock};
use tokio::sync::RwLock;
use tranquil_db_traits::SsoProviderType;
use tranquil_types::{SsoIssuer, SsoJwksUri};
use super::config::{AppleProviderConfig, ProviderConfig, SsoConfig};
use crate::cache::{Cache, cached_json};
use crate::cache_keys::{oidc_discovery_key, sso_jwks_key};
const SSO_HTTP_TIMEOUT: Duration = Duration::from_secs(15);
const SSO_DISCOVERY_TTL: Duration = Duration::from_secs(3600);
static APPLE_JWKS_URI: LazyLock<SsoJwksUri> = LazyLock::new(|| {
SsoJwksUri::new("https://appleid.apple.com/auth/keys")
.expect("Apple JWKS URI is a valid https URL")
});
struct PkceChallenge {
code_verifier: String,
@@ -28,6 +36,12 @@ fn create_http_client() -> Client {
Client::builder()
.timeout(SSO_HTTP_TIMEOUT)
.connect_timeout(Duration::from_secs(5))
.redirect(tranquil_types::redirect_policy(
tranquil_types::ReachPolicy::AllowPrivate,
))
.dns_resolver(tranquil_types::dns_guard(
tranquil_types::ReachPolicy::AllowPrivate,
))
.build()
.expect("Failed to create HTTP client")
}
@@ -367,16 +381,21 @@ impl SsoProvider for DiscordProvider {
}
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcDiscoveryConfig {
pub issuer: String,
pub issuer: SsoIssuer,
pub authorization_endpoint: String,
pub token_endpoint: String,
pub userinfo_endpoint: Option<String>,
pub jwks_uri: Option<String>,
#[serde(
default,
deserialize_with = "tranquil_types::http_url::deserialize_optional"
)]
pub jwks_uri: Option<SsoJwksUri>,
}
struct OidcDiscoveryCache {
#[derive(Serialize, Deserialize)]
struct OidcDiscovery {
config: OidcDiscoveryConfig,
jwks: Option<JwkSet>,
}
@@ -385,10 +404,10 @@ pub struct OidcProvider {
provider_type: SsoProviderType,
client_id: String,
client_secret: String,
issuer: String,
issuer: SsoIssuer,
display_name: String,
http_client: Client,
discovery_cache: OnceCell<OidcDiscoveryCache>,
cache: Arc<dyn Cache>,
}
impl OidcProvider {
@@ -397,11 +416,25 @@ impl OidcProvider {
config: &ProviderConfig,
default_issuer: Option<&str>,
default_name: &str,
cache: Arc<dyn Cache>,
) -> Option<Self> {
let issuer = config
let issuer = match config
.issuer
.clone()
.or_else(|| default_issuer.map(String::from))?;
.or_else(|| default_issuer.map(String::from))
.map(SsoIssuer::new)
{
Some(Ok(issuer)) => issuer,
Some(Err(e)) => {
tracing::error!(
provider = %provider_type.as_str(),
error = %e,
"SSO provider disabled because its issuer isn't a usable http or https URL"
);
return None;
}
None => return None,
};
Some(Self {
provider_type,
@@ -413,74 +446,80 @@ impl OidcProvider {
.clone()
.unwrap_or_else(|| default_name.to_string()),
http_client: create_http_client(),
discovery_cache: OnceCell::new(),
cache,
})
}
async fn get_discovery(&self) -> Result<&OidcDiscoveryCache, SsoError> {
self.discovery_cache
.get_or_try_init(|| async {
let discovery_url = format!(
"{}/.well-known/openid-configuration",
self.issuer.trim_end_matches('/')
);
async fn get_discovery(&self) -> Result<OidcDiscovery, SsoError> {
cached_json(
self.cache.as_ref(),
&oidc_discovery_key(&self.issuer),
SSO_DISCOVERY_TTL,
|| self.fetch_discovery(),
)
.await
}
tracing::debug!(
provider = %self.provider_type.as_str(),
url = %discovery_url,
"Fetching OIDC discovery document"
);
async fn fetch_discovery(&self) -> Result<OidcDiscovery, SsoError> {
let discovery_url = self.issuer.endpoint(".well-known/openid-configuration");
let resp = self
.http_client
.get(&discovery_url)
.send()
.await
.map_err(|e| SsoError::Discovery(e.to_string()))?;
tracing::debug!(
provider = %self.provider_type.as_str(),
url = %discovery_url,
"Fetching OIDC discovery document"
);
if !resp.status().is_success() {
return Err(SsoError::Discovery(format!(
"Discovery endpoint returned {}",
resp.status()
)));
}
let config: OidcDiscoveryConfig = resp
.json()
.await
.map_err(|e| SsoError::Discovery(e.to_string()))?;
let jwks = match &config.jwks_uri {
Some(jwks_uri) => {
tracing::debug!(
provider = %self.provider_type.as_str(),
url = %jwks_uri,
"Fetching JWKS"
);
let jwks_resp =
self.http_client.get(jwks_uri).send().await.map_err(|e| {
SsoError::Discovery(format!("JWKS fetch failed: {}", e))
})?;
if jwks_resp.status().is_success() {
Some(jwks_resp.json::<JwkSet>().await.map_err(|e| {
SsoError::Discovery(format!("JWKS parse failed: {}", e))
})?)
} else {
tracing::warn!(
provider = %self.provider_type.as_str(),
status = %jwks_resp.status(),
"JWKS fetch returned non-success status"
);
None
}
}
None => None,
};
Ok(OidcDiscoveryCache { config, jwks })
})
let resp = self
.http_client
.get(discovery_url)
.send()
.await
.map_err(|e| SsoError::Discovery(e.to_string()))?;
if !resp.status().is_success() {
return Err(SsoError::Discovery(format!(
"Discovery endpoint returned {}",
resp.status()
)));
}
let config: OidcDiscoveryConfig = resp
.json()
.await
.map_err(|e| SsoError::Discovery(e.to_string()))?;
let jwks =
match &config.jwks_uri {
Some(jwks_uri) => {
tracing::debug!(
provider = %self.provider_type.as_str(),
url = %jwks_uri,
"Fetching JWKS"
);
let jwks_resp = self
.http_client
.get(jwks_uri.as_str())
.send()
.await
.map_err(|e| SsoError::Discovery(format!("JWKS fetch failed: {}", e)))?;
if jwks_resp.status().is_success() {
Some(jwks_resp.json::<JwkSet>().await.map_err(|e| {
SsoError::Discovery(format!("JWKS parse failed: {}", e))
})?)
} else {
tracing::warn!(
provider = %self.provider_type.as_str(),
status = %jwks_resp.status(),
"JWKS fetch returned non-success status"
);
None
}
}
None => None,
};
Ok(OidcDiscovery { config, jwks })
}
fn generate_pkce() -> PkceChallenge {
@@ -602,9 +641,7 @@ impl SsoProvider for OidcProvider {
let auth_endpoint = match self.provider_type {
SsoProviderType::Google => "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
SsoProviderType::Gitlab => {
format!("{}/oauth/authorize", self.issuer.trim_end_matches('/'))
}
SsoProviderType::Gitlab => self.issuer.endpoint("oauth/authorize").to_string(),
_ => {
let discovery = self.get_discovery().await?;
discovery.config.authorization_endpoint.clone()
@@ -638,7 +675,7 @@ impl SsoProvider for OidcProvider {
) -> Result<SsoTokenResponse, SsoError> {
let token_endpoint = match self.provider_type {
SsoProviderType::Google => "https://oauth2.googleapis.com/token".to_string(),
SsoProviderType::Gitlab => format!("{}/oauth/token", self.issuer.trim_end_matches('/')),
SsoProviderType::Gitlab => self.issuer.endpoint("oauth/token").to_string(),
_ => {
let discovery = self.get_discovery().await?;
discovery.config.token_endpoint.clone()
@@ -721,9 +758,7 @@ impl SsoProvider for OidcProvider {
SsoProviderType::Google => {
"https://openidconnect.googleapis.com/v1/userinfo".to_string()
}
SsoProviderType::Gitlab => {
format!("{}/oauth/userinfo", self.issuer.trim_end_matches('/'))
}
SsoProviderType::Gitlab => self.issuer.endpoint("oauth/userinfo").to_string(),
_ => {
let discovery = self.get_discovery().await?;
discovery
@@ -777,11 +812,11 @@ pub struct AppleProvider {
private_key_pem: String,
http_client: Client,
client_secret_cache: RwLock<Option<CachedClientSecret>>,
jwks_cache: OnceCell<JwkSet>,
cache: Arc<dyn Cache>,
}
impl AppleProvider {
pub fn new(config: &AppleProviderConfig) -> Result<Self, SsoError> {
pub fn new(config: &AppleProviderConfig, cache: Arc<dyn Cache>) -> Result<Self, SsoError> {
let key_pem = config.private_key_pem.replace("\\n", "\n");
jsonwebtoken::EncodingKey::from_ec_pem(key_pem.as_bytes())
@@ -794,7 +829,7 @@ impl AppleProvider {
private_key_pem: key_pem,
http_client: create_http_client(),
client_secret_cache: RwLock::new(None),
jwks_cache: OnceCell::new(),
cache,
})
}
@@ -868,29 +903,35 @@ impl AppleProvider {
Ok(generated.secret)
}
async fn get_jwks(&self) -> Result<&JwkSet, SsoError> {
self.jwks_cache
.get_or_try_init(|| async {
tracing::debug!("Fetching Apple JWKS");
let resp = self
.http_client
.get("https://appleid.apple.com/auth/keys")
.send()
.await
.map_err(|e| SsoError::Discovery(format!("Apple JWKS fetch failed: {}", e)))?;
async fn get_jwks(&self) -> Result<JwkSet, SsoError> {
cached_json(
self.cache.as_ref(),
&sso_jwks_key(&APPLE_JWKS_URI),
SSO_DISCOVERY_TTL,
|| self.fetch_jwks(),
)
.await
}
if !resp.status().is_success() {
return Err(SsoError::Discovery(format!(
"Apple JWKS returned {}",
resp.status()
)));
}
resp.json::<JwkSet>()
.await
.map_err(|e| SsoError::Discovery(format!("Apple JWKS parse failed: {}", e)))
})
async fn fetch_jwks(&self) -> Result<JwkSet, SsoError> {
tracing::debug!("Fetching Apple JWKS");
let resp = self
.http_client
.get(APPLE_JWKS_URI.as_str())
.send()
.await
.map_err(|e| SsoError::Discovery(format!("Apple JWKS fetch failed: {}", e)))?;
if !resp.status().is_success() {
return Err(SsoError::Discovery(format!(
"Apple JWKS returned {}",
resp.status()
)));
}
resp.json()
.await
.map_err(|e| SsoError::Discovery(format!("Apple JWKS parse failed: {}", e)))
}
fn validate_id_token(
@@ -1043,7 +1084,7 @@ impl SsoProvider for AppleProvider {
})?;
let jwks = self.get_jwks().await?;
let claims = self.validate_id_token(id_token, jwks, expected_nonce)?;
let claims = self.validate_id_token(id_token, &jwks, expected_nonce)?;
tracing::debug!(
sub = %claims.sub,
@@ -1063,10 +1104,11 @@ impl SsoProvider for AppleProvider {
#[derive(Clone)]
pub struct SsoManager {
providers: HashMap<SsoProviderType, Arc<dyn SsoProvider>>,
config: &'static SsoConfig,
}
impl SsoManager {
pub fn from_config(config: &SsoConfig) -> Self {
pub fn from_config(config: &'static SsoConfig, cache: Arc<dyn Cache>) -> Self {
let mut providers: HashMap<SsoProviderType, Arc<dyn SsoProvider>> = HashMap::new();
if let Some(ref cfg) = config.github {
@@ -1086,13 +1128,15 @@ impl SsoManager {
cfg,
Some("https://accounts.google.com"),
"Google",
cache.clone(),
)
{
providers.insert(SsoProviderType::Google, Arc::new(provider));
}
if let Some(ref cfg) = config.gitlab
&& let Some(provider) = OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab")
&& let Some(provider) =
OidcProvider::new(SsoProviderType::Gitlab, cfg, None, "GitLab", cache.clone())
{
providers.insert(SsoProviderType::Gitlab, Arc::new(provider));
}
@@ -1103,13 +1147,14 @@ impl SsoManager {
cfg,
None,
cfg.display_name.as_deref().unwrap_or("SSO"),
cache.clone(),
)
{
providers.insert(SsoProviderType::Oidc, Arc::new(provider));
}
if let Some(ref cfg) = config.apple {
match AppleProvider::new(cfg) {
match AppleProvider::new(cfg, cache.clone()) {
Ok(provider) => {
providers.insert(SsoProviderType::Apple, Arc::new(provider));
}
@@ -1119,7 +1164,11 @@ impl SsoManager {
}
}
Self { providers }
Self { providers, config }
}
pub fn config(&self) -> &'static SsoConfig {
self.config
}
pub fn get_provider(&self, provider_type: SsoProviderType) -> Option<Arc<dyn SsoProvider>> {
@@ -1137,9 +1186,3 @@ impl SsoManager {
!self.providers.is_empty()
}
}
impl Default for SsoManager {
fn default() -> Self {
Self::from_config(SsoConfig::get())
}
}
+34 -7
View File
@@ -15,10 +15,12 @@ use std::error::Error;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use tranquil_db::PostgresRepositories;
use tranquil_db_traits::SequencedEvent;
use tranquil_oauth::ClientMetadataCache;
static RATE_LIMITING_DISABLED: AtomicBool = AtomicBool::new(false);
@@ -49,6 +51,7 @@ pub struct AppState {
pub sso_manager: SsoManager,
pub webauthn_config: Arc<WebAuthnConfig>,
pub cross_pds_oauth: Arc<CrossPdsOAuthClient>,
pub client_metadata_cache: ClientMetadataCache,
pub shutdown: CancellationToken,
pub bootstrap_invite_code: Option<crate::types::InviteCode>,
pub signal_sender: Option<Arc<tranquil_signal::SignalSlot>>,
@@ -210,6 +213,27 @@ impl RateLimitKind {
}
}
const CLIENT_METADATA_TTL: Duration = Duration::from_secs(3600);
struct CacheBound {
did_resolver: Arc<DidResolver>,
cross_pds_oauth: Arc<CrossPdsOAuthClient>,
client_metadata_cache: ClientMetadataCache,
sso_manager: SsoManager,
}
impl CacheBound {
fn new(cache: &Arc<dyn Cache>, sso_config: &'static SsoConfig) -> Self {
tranquil_lexicon::LexiconRegistry::global().set_shared_cache(cache.clone());
Self {
did_resolver: Arc::new(DidResolver::new(cache.clone())),
cross_pds_oauth: Arc::new(CrossPdsOAuthClient::new(cache.clone())),
client_metadata_cache: ClientMetadataCache::new(cache.clone(), CLIENT_METADATA_TTL),
sso_manager: SsoManager::from_config(sso_config, cache.clone()),
}
}
}
impl AppState {
pub fn plc_client(&self) -> PlcClient {
PlcClient::with_cache(None, Some(self.cache.clone()))
@@ -366,10 +390,7 @@ impl AppState {
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone())
.await
.expect("Failed to initialize cache and distributed rate limiter at startup");
let did_resolver = Arc::new(DidResolver::new());
let cross_pds_oauth = Arc::new(CrossPdsOAuthClient::new(cache.clone()));
let sso_config = SsoConfig::init();
let sso_manager = SsoManager::from_config(sso_config);
let bound = CacheBound::new(&cache, SsoConfig::init());
let webauthn_config = Arc::new(
WebAuthnConfig::new(&cfg.server.hostname)
.expect("Failed to create WebAuthn config at startup"),
@@ -385,9 +406,10 @@ impl AppState {
circuit_breakers,
cache,
distributed_rate_limiter,
did_resolver,
cross_pds_oauth,
sso_manager,
did_resolver: bound.did_resolver,
cross_pds_oauth: bound.cross_pds_oauth,
client_metadata_cache: bound.client_metadata_cache,
sso_manager: bound.sso_manager,
webauthn_config,
shutdown,
bootstrap_invite_code: None,
@@ -410,6 +432,11 @@ impl AppState {
cache: Arc<dyn Cache>,
distributed_rate_limiter: Arc<dyn DistributedRateLimiter>,
) -> Self {
let bound = CacheBound::new(&cache, self.sso_manager.config());
self.did_resolver = bound.did_resolver;
self.cross_pds_oauth = bound.cross_pds_oauth;
self.client_metadata_cache = bound.client_metadata_cache;
self.sso_manager = bound.sso_manager;
self.cache = cache;
self.distributed_rate_limiter = distributed_rate_limiter;
self
+1 -1
View File
@@ -2,5 +2,5 @@ pub use tranquil_storage::{
BlobStorage, FilesystemBlobStorage, StorageError, StreamUploadResult, create_blob_storage,
};
#[cfg(feature = "s3-storage")]
#[cfg(feature = "s3")]
pub use tranquil_storage::S3BlobStorage;
+3
View File
@@ -1,7 +1,10 @@
pub use tranquil_types::*;
#[cfg(feature = "bsky")]
use std::sync::LazyLock;
#[cfg(feature = "bsky")]
pub static PROFILE_COLLECTION: LazyLock<Nsid> =
LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap());
#[cfg(feature = "bsky")]
pub static PROFILE_RKEY: LazyLock<Rkey> = LazyLock::new(|| "self".parse().unwrap());
+12 -2
View File
@@ -87,7 +87,12 @@ pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName =
pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev");
pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName =
HeaderName::from_static("atproto-content-labelers");
#[cfg(feature = "bsky-support")]
pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics");
#[cfg(feature = "bsky-support")]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 1] = [HEADER_X_BSKY_TOPICS];
#[cfg(not(feature = "bsky-support"))]
pub const CORS_BSKY_ALLOW_HEADERS: [HeaderName; 0] = [];
pub fn get_header_str(
headers: &HeaderMap,
@@ -247,7 +252,9 @@ pub fn build_full_url(path: &str) -> String {
let cfg = tranquil_config::get();
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
// BSKY: Bluesky requires that the PDS implement some app.bsky.* endpoints so we need to deal with those here too.
// TODO: surely we can figure out a way to do this more generically?
|| (cfg!(feature = "bsky-support") && path.starts_with("/app.bsky."))
|| path.starts_with("/_"))
{
format!("/xrpc{path}")
@@ -791,7 +798,10 @@ mod tests {
);
assert_eq!(
build_full_url("/app.bsky.feed.getTimeline"),
"https://example.com/xrpc/app.bsky.feed.getTimeline"
match cfg!(feature = "bsky-support") {
true => "https://example.com/xrpc/app.bsky.feed.getTimeline",
false => "https://example.com/app.bsky.feed.getTimeline",
}
);
assert_eq!(
build_full_url("/_health"),
+11
View File
@@ -132,26 +132,35 @@ fn validate_preamble<'a>(
Ok((record_type, obj))
}
#[cfg_attr(
not(feature = "bsky"),
expect(unused_variables, reason = "only bsky record checks read obj and rkey")
)]
fn check_banned_content(
record_type: &str,
obj: &serde_json::Map<String, Value>,
rkey: Option<&Rkey>,
) -> Result<(), ValidationError> {
match record_type {
#[cfg(feature = "bsky")]
"app.bsky.feed.post" => {
check_post_banned_content(obj)?;
}
#[cfg(feature = "bsky")]
"app.bsky.actor.profile" => {
check_string_field(obj, "displayName")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.list" => {
check_string_field(obj, "name")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.starterpack" => {
check_string_field(obj, "name")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.feed.generator" => {
if let Some(rkey) = rkey
&& crate::moderation::has_explicit_slur(rkey)
@@ -167,6 +176,7 @@ fn check_banned_content(
Ok(())
}
#[cfg(feature = "bsky")]
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
tags.iter().enumerate().try_for_each(|(i, tag)| {
@@ -205,6 +215,7 @@ fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(),
Ok(())
}
#[cfg(feature = "bsky")]
fn check_string_field(
obj: &serde_json::Map<String, Value>,
field: &str,
+8 -8
View File
@@ -1,8 +1,8 @@
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_config::BehaviorVersion;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_sdk_s3::Client as S3Client;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use aws_sdk_s3::config::Credentials;
use chrono::Utc;
use reqwest::{Client, StatusCode, header};
@@ -56,9 +56,9 @@ pub struct ServerInstance {
pub distributed_rate_limiter: Option<Arc<dyn DistributedRateLimiter>>,
}
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use testcontainers::GenericImage;
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
use testcontainers::core::ContainerPort;
#[cfg(not(feature = "external-infra"))]
use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
@@ -66,7 +66,7 @@ use testcontainers::{ContainerAsync, ImageExt, runners::AsyncRunner};
use testcontainers_modules::postgres::Postgres;
#[cfg(not(feature = "external-infra"))]
static DB_CONTAINER: OnceLock<ContainerAsync<Postgres>> = OnceLock::new();
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
static S3_CONTAINER: OnceLock<ContainerAsync<GenericImage>> = OnceLock::new();
#[allow(dead_code)]
@@ -195,7 +195,7 @@ async fn setup_with_external_infra() -> String {
spawn_app(database_url).await
}
#[cfg(all(not(feature = "external-infra"), not(feature = "s3-storage")))]
#[cfg(all(not(feature = "external-infra"), not(feature = "s3")))]
async fn setup_with_testcontainers() -> String {
let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-test-{}", uuid::Uuid::new_v4()));
let blob_path = temp_dir.join("blobs");
@@ -227,7 +227,7 @@ async fn setup_with_testcontainers() -> String {
spawn_app(connection_string).await
}
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
#[cfg(all(not(feature = "external-infra"), feature = "s3"))]
async fn setup_with_testcontainers() -> String {
let s3_container = GenericImage::new("cgr.dev/chainguard/minio", "latest")
.with_exposed_port(ContainerPort::Tcp(9000))
+4 -1
View File
@@ -177,7 +177,10 @@ async fn test_external_did_web_no_local_doc() {
async fn reserve_signing_key(client: &reqwest::Client, base: &str, did: &str) -> String {
let res = client
.post(format!("{}/xrpc/com.atproto.server.reserveSigningKey", base))
.post(format!(
"{}/xrpc/com.atproto.server.reserveSigningKey",
base
))
.json(&json!({ "did": did }))
.send()
.await
+3 -2
View File
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token,
verify_token,
};
use tranquil_types::{Did, Nsid};
use tranquil_types::{Did, DidRef, Nsid};
fn generate_user_key() -> Vec<u8> {
let secret_key = SecretKey::random(&mut OsRng);
@@ -169,8 +169,9 @@ fn test_token_type_confusion() {
let service_token = create_service_token(
&did,
&Did::new("did:web:nel.pet").expect("valid DID"),
&DidRef::new("did:web:nel.pet").expect("valid DID reference"),
Some(&Nsid::new("cafe.oyster.method").expect("valid NSID")),
None,
&key_bytes,
)
.unwrap();
+57
View File
@@ -1270,6 +1270,63 @@ async fn test_granular_scope_rpc_specific_method() {
);
}
#[tokio::test]
async fn test_granular_scope_rpc_aud_with_service_id() {
let url = base_url().await;
let http_client = client();
let (token, _, _) = get_oauth_token_with_scope(
"rpc:app.bsky.feed.getTimeline?aud=did:web:api.bsky.app#bsky_appview",
)
.await;
let allowed_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[
("aud", "did:web:api.bsky.app#bsky_appview"),
("lxm", "app.bsky.feed.getTimeline"),
])
.send()
.await
.unwrap();
assert_eq!(
allowed_res.status(),
StatusCode::OK,
"the granted service id must cover a request naming it"
);
let body: Value = allowed_res.json().await.unwrap();
let service_token = body["token"].as_str().unwrap();
let payload = service_token.split('.').nth(1).unwrap();
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).unwrap()).unwrap();
assert_eq!(
claims["aud"], "did:web:api.bsky.app#bsky_appview",
"the service id must reach the signed claim even on the granular scope path"
);
for (aud, reason) in [
(
"did:web:api.bsky.app#atproto_labeler",
"a scope for the appview must not mint tokens for the labeler on the same DID",
),
(
"did:web:api.bsky.app",
"a scope for one service must not widen to the whole DID",
),
(
"did:web:other.example#bsky_appview",
"a service id must not smuggle in a different audience",
),
] {
let blocked_res = http_client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
.bearer_auth(&token)
.query(&[("aud", aud), ("lxm", "app.bsky.feed.getTimeline")])
.send()
.await
.unwrap();
assert_eq!(blocked_res.status(), StatusCode::FORBIDDEN, "{reason}");
}
}
#[tokio::test]
async fn test_oauth_metadata_includes_prompt_values_supported() {
let url = base_url().await;
+32 -2
View File
@@ -605,6 +605,7 @@ async fn test_oauth_multiple_clients_same_user() {
("redirect_uri", "https://client1.example.com/callback"),
("code_challenge", &challenge1),
("code_challenge_method", "S256"),
("scope", "atproto repo:app.bsky.feed.post?action=create"),
])
.send()
.await
@@ -631,7 +632,7 @@ async fn test_oauth_multiple_clients_same_user() {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
.json(&json!({"request_uri": request_uri1, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location1 = consent_body["redirect_uri"].as_str().unwrap().to_string();
@@ -666,6 +667,7 @@ async fn test_oauth_multiple_clients_same_user() {
("redirect_uri", "https://client2.example.com/callback"),
("code_challenge", &challenge2),
("code_challenge_method", "S256"),
("scope", "atproto repo:app.bsky.feed.post?action=create"),
])
.send()
.await
@@ -692,7 +694,7 @@ async fn test_oauth_multiple_clients_same_user() {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "transition:generic"], "remember": false}))
.json(&json!({"request_uri": request_uri2, "approved_scopes": ["atproto", "repo:app.bsky.feed.post?action=create"], "remember": false}))
.send().await.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location2 = consent_body["redirect_uri"].as_str().unwrap().to_string();
@@ -763,6 +765,33 @@ async fn test_oauth_multiple_clients_same_user() {
StatusCode::OK,
"Client 2 token should work"
);
let ungranted_collection = "app.bsky.graph.follow";
let denied_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(token1)
.json(&json!({
"repo": user_did,
"collection": ungranted_collection,
"record": {
"$type": ungranted_collection,
"subject": user_did,
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.unwrap();
assert_eq!(
denied_res.status(),
StatusCode::FORBIDDEN,
"Client 1 token shouldn't write outside its granted collection"
);
let denied_body: Value = denied_res.json().await.unwrap();
assert_eq!(
denied_body["error"].as_str(),
Some("InsufficientScope"),
"Write outside the granted collection should fail on scope"
);
let list_res = http_client
.get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
.bearer_auth(token1)
@@ -770,6 +799,7 @@ async fn test_oauth_multiple_clients_same_user() {
.send()
.await
.unwrap();
assert_eq!(list_res.status(), StatusCode::OK, "listRecords should work");
let list_body: Value = list_res.json().await.unwrap();
let records = list_body["records"].as_array().unwrap();
assert_eq!(
@@ -15,6 +15,13 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
const PERMISSION_SET_NSID: &str = "io.atcr.authFullApp";
const PERMISSION_SET_GRANULAR_SCOPE: &str =
"repo:io.atcr.manifest?action=create rpc:io.atcr.getManifest?aud=*";
const PERMISSION_SET_MULTI_ACTION_SCOPE: &str =
"repo:io.atcr.manifest?action=create&action=update&action=delete";
const EDITOR_SET_NSID: &str = "io.atcr.authEditorApp";
const SUBSET_SET_NSID: &str = "io.atcr.authSubsetApp";
const PERMISSION_SET_CREATE_DELETE_SCOPE: &str =
"repo:io.atcr.manifest?action=create&action=delete";
const CREATE_ONLY_GRANT: &str = "atproto repo:*?action=create blob:*/*";
fn disable_rate_limiting_once() {
static ONCE: std::sync::Once = std::sync::Once::new();
@@ -105,6 +112,21 @@ async fn create_delegated_session_with_scope(
handle_prefix: &str,
redirect_uri: &str,
scope: &str,
) -> (DelegatedSession, Value, MockServer) {
create_delegated_session_with_grant(
handle_prefix,
redirect_uri,
scope,
tranquil_pds::delegation::OWNER_FULL_SCOPES,
)
.await
}
async fn create_delegated_session_with_grant(
handle_prefix: &str,
redirect_uri: &str,
scope: &str,
controller_scopes: &str,
) -> (DelegatedSession, Value, MockServer) {
let url = base_url().await;
disable_rate_limiting_once();
@@ -119,7 +141,7 @@ async fn create_delegated_session_with_scope(
.bearer_auth(&controller_jwt)
.json(&json!({
"handle": delegated_handle,
"controllerScopes": tranquil_pds::delegation::OWNER_FULL_SCOPES
"controllerScopes": controller_scopes
}))
.send()
.await
@@ -375,6 +397,131 @@ async fn test_delegated_include_scope_shows_granular_on_consent() {
);
}
#[tokio::test]
async fn test_delegated_editor_grant_keeps_collapsed_permission_set() {
seed_permission_set(EDITOR_SET_NSID, PERMISSION_SET_MULTI_ACTION_SCOPE).await;
let scope = format!("atproto include:{}", EDITOR_SET_NSID);
let (session, consent_body, _mock) = create_delegated_session_with_grant(
"pse",
"https://example.com/permset-editor-callback",
&scope,
tranquil_pds::delegation::EDITOR_FULL_SCOPES,
)
.await;
let set_entry = consent_body["permission_sets"]
.as_array()
.expect("consent response should have a permission_sets array")
.iter()
.find(|s| s["nsid"].as_str() == Some(EDITOR_SET_NSID))
.unwrap_or_else(|| {
panic!(
"permission_sets should contain an entry for nsid '{}'. Got: {:?}",
EDITOR_SET_NSID, consent_body
)
});
assert_eq!(
set_entry["restricted"].as_bool(),
Some(false),
"an editor grant spells its actions as separate tokens, but it still permits every \
action in the collapsed set, so the set must not be marked restricted. Got: {:?}",
set_entry
);
let payload = decode_jwt_payload(&session.access_token);
let jwt_scope = tranquil_pds::auth::decode_scope(
payload["scope"]
.as_str()
.expect("access token JWT should have a scope claim"),
)
.expect("JWT scope claim should decode");
assert!(
jwt_scope.contains(PERMISSION_SET_MULTI_ACTION_SCOPE),
"delegated intersection must narrow the collapsed repo scope rather than discard it, \
expected '{}' in decoded scope, got: {}",
PERMISSION_SET_MULTI_ACTION_SCOPE,
jwt_scope
);
}
#[tokio::test]
async fn test_delegated_consent_shows_the_scope_the_token_will_carry() {
seed_permission_set(SUBSET_SET_NSID, PERMISSION_SET_CREATE_DELETE_SCOPE).await;
let scope = format!("atproto include:{}", SUBSET_SET_NSID);
let (session, consent_body, _mock) = create_delegated_session_with_grant(
"pss",
"https://example.com/permset-subset-callback",
&scope,
CREATE_ONLY_GRANT,
)
.await;
let set_entry = consent_body["permission_sets"]
.as_array()
.expect("consent response should have a permission_sets array")
.iter()
.find(|s| s["nsid"].as_str() == Some(SUBSET_SET_NSID))
.unwrap_or_else(|| {
panic!(
"permission_sets should contain an entry for nsid '{}'. Got: {:?}",
SUBSET_SET_NSID, consent_body
)
});
assert_eq!(
set_entry["restricted"].as_bool(),
Some(false),
"the create action is still granted, so the set stays approvable. Got: {:?}",
set_entry
);
let repo = set_entry["expanded"]
.as_array()
.expect("permission_sets entry should have an expanded array")
.iter()
.find(|s| s["scope"].as_str() == Some(PERMISSION_SET_CREATE_DELETE_SCOPE))
.unwrap_or_else(|| {
panic!(
"expanded[] should list the requested scope '{}'. Got: {:?}",
PERMISSION_SET_CREATE_DELETE_SCOPE, set_entry
)
});
assert_eq!(
repo["restricted"].as_bool(),
Some(false),
"a partially-covered scope is neither fully granted nor withheld. Got: {:?}",
repo
);
let effective_scope = repo["effective_scope"].as_str().unwrap_or_else(|| {
panic!(
"a scope the grant narrows must report the actions it actually confers. Got: {:?}",
repo
)
});
assert_eq!(effective_scope, "repo:io.atcr.manifest?action=create");
let payload = decode_jwt_payload(&session.access_token);
let jwt_scope = tranquil_pds::auth::decode_scope(
payload["scope"]
.as_str()
.expect("access token JWT should have a scope claim"),
)
.expect("JWT scope claim should decode");
assert!(
jwt_scope.split_whitespace().any(|s| s == effective_scope),
"the consent screen must show the scope the token carries, expected '{}' in decoded \
scope, got: {}",
effective_scope,
jwt_scope
);
assert!(
!jwt_scope.contains("action=delete"),
"the grant confers no delete action, so the token must not carry one, got: {}",
jwt_scope
);
}
#[tokio::test]
async fn test_grant_row_keeps_include_jwt_carries_expanded() {
seed_permission_set(PERMISSION_SET_NSID, PERMISSION_SET_GRANULAR_SCOPE).await;
@@ -505,6 +652,129 @@ async fn test_enforcement_uses_expanded_jwt_scope() {
);
}
#[tokio::test]
async fn test_long_expanded_scope_is_compressed_in_jwt() {
const BIG_SET_NSID: &str = "io.atcr.authBigApp";
let collections = [
"io.atcr.manifest",
"io.atcr.sailor.star",
"io.atcr.tag",
"io.atcr.blueprint",
"io.atcr.artifact",
"io.atcr.channel.read",
];
let granular_scope = collections
.iter()
.map(|coll| format!("repo:{}?action=create&action=delete", coll))
.collect::<Vec<_>>()
.join(" ");
seed_permission_set(BIG_SET_NSID, &granular_scope).await;
let scope = format!("atproto include:{}", BIG_SET_NSID);
let (session, _consent_body, _mock) = create_delegated_session_with_scope(
"psc",
"https://example.com/permset-compress-callback",
&scope,
)
.await;
let payload = decode_jwt_payload(&session.access_token);
let jwt_scope = payload["scope"]
.as_str()
.expect("access token JWT should have a scope claim");
assert!(
jwt_scope.starts_with("$br$"),
"an expanded scope this long should be compressed in the JWT claim, got: {}",
jwt_scope
);
let decoded = tranquil_pds::auth::decode_scope(jwt_scope).expect("scope claim should decode");
for coll in collections {
assert!(
decoded.contains(&format!("repo:{}?action=create", coll)),
"decoded scope should carry {}, got: {}",
coll,
decoded
);
}
assert!(
!decoded.contains("include:"),
"decoded scope should not contain the raw include: token, got: {}",
decoded
);
let url = base_url().await;
let http_client = client();
let introspect_res = http_client
.post(format!("{}/oauth/introspect", url))
.form(&[("token", session.access_token.as_str())])
.send()
.await
.expect("introspect request failed");
assert_eq!(introspect_res.status(), StatusCode::OK);
let introspect_body: Value = introspect_res.json().await.unwrap();
let introspect_scope = introspect_body["scope"]
.as_str()
.expect("introspect response should have a scope string");
assert_eq!(
introspect_scope, decoded,
"introspect should report the decoded scope"
);
let collection = collections[0];
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
.bearer_auth(&session.access_token)
.json(&json!({
"repo": session.delegated_did,
"collection": collection,
"validate": false,
"record": {
"$type": collection,
"note": "compressed scope enforcement test",
"createdAt": Utc::now().to_rfc3339()
}
}))
.send()
.await
.expect("createRecord request failed");
assert_ne!(
create_res.status(),
StatusCode::FORBIDDEN,
"a compressed scope claim must still authorize the collections it covers. Got body: {:?}",
create_res.text().await
);
let refresh_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "refresh_token"),
("refresh_token", session.refresh_token.as_str()),
("client_id", session.client_id.as_str()),
])
.send()
.await
.expect("Refresh request failed");
assert_eq!(refresh_res.status(), StatusCode::OK);
let refresh_body: Value = refresh_res.json().await.unwrap();
let refreshed_token = refresh_body["access_token"].as_str().unwrap();
let refreshed_claim = decode_jwt_payload(refreshed_token)["scope"]
.as_str()
.expect("refreshed JWT should have a scope claim")
.to_string();
assert!(
refreshed_claim.starts_with("$br$"),
"refreshed claim should also be compressed, got: {}",
refreshed_claim
);
assert_eq!(
tranquil_pds::auth::decode_scope(&refreshed_claim).expect("refreshed scope should decode"),
decoded,
"refresh must yield a byte-identical decoded scope"
);
}
#[tokio::test]
async fn test_consent_post_errors_when_set_unresolvable() {
const UNRESOLVABLE_NSID: &str = "io.atcr.authUnresolvableSet";
+58 -9
View File
@@ -181,6 +181,52 @@ fn test_permissions_rpc_lxm_wildcard_prefix() {
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.actor.getProfile")));
}
#[test]
fn test_permissions_rpc_aud_service_id_must_match_verbatim() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.*?aud=did:web:api.bsky.app"));
assert!(
perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"the granted audience must cover itself"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a bare DID grants nothing to the services listed under it"
);
assert!(
!perms.allows_rpc(
"did:web:other.example#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"a service id must not smuggle in a different audience"
);
let fragment_scope = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.*?aud=did:web:api.bsky.app%23bsky_appview",
));
assert!(
fragment_scope.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getTimeline")
),
"the granted service id must cover itself"
);
assert!(
!fragment_scope.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")),
"a scope granted for one service must not widen to the whole DID"
);
assert!(
!fragment_scope.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getTimeline")
),
"the appview and the labeler are different audiences even on one DID"
);
}
#[test]
fn test_delegation_intersect_mismatched_params_empty() {
let result = intersect_scopes("repo:*?action=create", "repo:*?action=delete");
@@ -254,15 +300,18 @@ fn test_scope_with_multiple_params() {
}
#[test]
fn test_scope_invalid_action_ignored() {
let scope = parse_scope("repo:*?action=invalid");
if let ParsedScope::Repo(repo) = scope {
assert!(repo.actions.contains(&RepoAction::Create));
assert!(repo.actions.contains(&RepoAction::Update));
assert!(repo.actions.contains(&RepoAction::Delete));
} else {
panic!("Expected Repo scope");
}
fn test_scope_invalid_action_rejects_whole_scope() {
assert!(
matches!(
parse_scope("repo:*?action=invalid"),
ParsedScope::Unknown(_)
),
"an unrecognized action must not fall back to granting every action"
);
assert!(matches!(
parse_scope("repo:*?action=create&action=invalid"),
ParsedScope::Unknown(_)
));
}
#[test]
+40
View File
@@ -132,6 +132,46 @@ async fn test_service_auth() {
let lxm_payload = URL_SAFE_NO_PAD.decode(lxm_parts[1]).unwrap();
let lxm_claims: Value = serde_json::from_slice(&lxm_payload).unwrap();
assert_eq!(lxm_claims["lxm"], "com.atproto.repo.getRecord");
let fragment_res = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[
("aud", "did:web:example.com#colibri_appview"),
("lxm", "com.atproto.repo.getRecord"),
])
.send()
.await
.unwrap();
assert_eq!(fragment_res.status(), StatusCode::OK);
let fragment_body: Value = fragment_res.json().await.unwrap();
let fragment_token = fragment_body["token"].as_str().unwrap();
let fragment_parts: Vec<&str> = fragment_token.split('.').collect();
let fragment_payload = URL_SAFE_NO_PAD.decode(fragment_parts[1]).unwrap();
let fragment_claims: Value = serde_json::from_slice(&fragment_payload).unwrap();
assert_eq!(
fragment_claims["aud"], "did:web:example.com#colibri_appview",
"the service id must survive into the signed claim so the receiver can match it \
against its own DID document"
);
let empty_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#")])
.send()
.await
.unwrap();
assert_eq!(empty_fragment.status(), StatusCode::BAD_REQUEST);
let double_fragment = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.bearer_auth(&access_jwt)
.query(&[("aud", "did:web:example.com#a#b")])
.send()
.await
.unwrap();
assert_eq!(double_fragment.status(), StatusCode::BAD_REQUEST);
let unauth = client
.get(format!("{}/xrpc/com.atproto.server.getServiceAuth", base))
.query(&[("aud", "did:web:example.com")])
+136 -7
View File
@@ -1,7 +1,8 @@
use crate::parser::{
AccountAction, AccountAttr, AccountScope, BlobScope, IdentityAttr, IdentityScope, ParsedScope,
RepoScope, RpcScope,
RepoAction, RepoScope, RpcScope,
};
use std::collections::HashSet;
pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool {
use ParsedScope::*;
@@ -21,8 +22,8 @@ pub fn covers(granted: &ParsedScope, requested: &ParsedScope) -> bool {
}
}
fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool {
let collection_ok = match &g.collection {
fn repo_collection_covers(g: &RepoScope, r: &RepoScope) -> bool {
match &g.collection {
None => true,
Some(gc) => match &r.collection {
None => false,
@@ -33,8 +34,54 @@ fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool {
None => gc == rc,
},
},
};
collection_ok && r.actions.is_subset(&g.actions)
}
}
fn repo_covers(g: &RepoScope, r: &RepoScope) -> bool {
repo_collection_covers(g, r) && r.actions.is_subset(&g.actions)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Coverage {
Full,
Narrowed(ParsedScope),
Withheld,
}
pub fn coverage(granted: &[ParsedScope], requested: &ParsedScope) -> Coverage {
if let ParsedScope::Repo(r) = requested {
let actions: HashSet<RepoAction> = granted
.iter()
.filter_map(|g| match g {
ParsedScope::Repo(g) if repo_collection_covers(g, r) => Some(&g.actions),
_ => None,
})
.flat_map(|granted_actions| granted_actions.intersection(&r.actions).copied())
.collect();
return match actions.len() {
0 => Coverage::Withheld,
_ if actions == r.actions => Coverage::Full,
_ => Coverage::Narrowed(ParsedScope::Repo(RepoScope {
collection: r.collection.clone(),
actions,
})),
};
}
if granted.iter().any(|g| covers(g, requested)) {
Coverage::Full
} else {
Coverage::Withheld
}
}
pub fn narrow(granted: &[ParsedScope], requested: &ParsedScope) -> Option<ParsedScope> {
match coverage(granted, requested) {
Coverage::Full => Some(requested.clone()),
Coverage::Narrowed(scope) => Some(scope),
Coverage::Withheld => None,
}
}
fn blob_covers(g: &BlobScope, r: &BlobScope) -> bool {
@@ -74,13 +121,33 @@ fn identity_covers(g: &IdentityScope, r: &IdentityScope) -> bool {
#[cfg(test)]
mod tests {
use super::covers;
use crate::parser::parse_scope;
use super::{Coverage, coverage, covers, narrow};
use crate::parser::{ParsedScope, parse_scope};
fn c(granted: &str, requested: &str) -> bool {
covers(&parse_scope(granted), &parse_scope(requested))
}
fn narrowed(granted: &str, requested: &str) -> Option<String> {
let granted: Vec<ParsedScope> = granted.split_whitespace().map(parse_scope).collect();
match narrow(&granted, &parse_scope(requested)) {
Some(ParsedScope::Repo(repo)) => Some(repo.to_scope_string()),
Some(_) => Some(requested.to_string()),
None => None,
}
}
fn covered(granted: &str, requested: &str) -> Coverage {
let granted: Vec<ParsedScope> = granted.split_whitespace().map(parse_scope).collect();
coverage(&granted, &parse_scope(requested))
}
fn narrowed_to(scope: &str) -> Coverage {
Coverage::Narrowed(parse_scope(scope))
}
#[test]
fn repo_wildcard_covers_specific() {
assert!(c("repo:*", "repo:app.bsky.feed.post"));
@@ -193,4 +260,66 @@ mod tests {
assert!(c("weird:token", "weird:token"));
assert!(!c("weird:token", "other:token"));
}
#[test]
fn narrow_intersects_repo_actions() {
assert_eq!(
narrowed(
"repo:*?action=create repo:*?action=update repo:*?action=delete",
"repo:io.atcr.manifest?action=create&action=delete"
),
Some("repo:io.atcr.manifest?action=create&action=delete".to_string())
);
assert_eq!(
narrowed(
"repo:*?action=create",
"repo:io.atcr.manifest?action=create&action=delete"
),
Some("repo:io.atcr.manifest?action=create".to_string())
);
assert_eq!(
narrowed(
"repo:*?action=create",
"repo:io.atcr.manifest?action=delete"
),
None
);
assert_eq!(narrowed("repo:app.bsky.*?action=create", "repo:*"), None);
assert_eq!(
narrowed("identity:*", "identity:handle"),
Some("identity:handle".to_string())
);
}
#[test]
fn coverage_distinguishes_full_from_narrowed_repo_actions() {
assert_eq!(
covered(
"repo:*?action=create repo:*?action=update repo:*?action=delete",
"repo:io.atcr.manifest?action=create&action=delete"
),
Coverage::Full
);
assert_eq!(
covered(
"repo:*?action=create",
"repo:io.atcr.manifest?action=create&action=delete"
),
narrowed_to("repo:io.atcr.manifest?action=create")
);
assert_eq!(
covered(
"repo:*?action=create&action=delete",
"repo:io.atcr.manifest"
),
narrowed_to("repo:io.atcr.manifest?action=create&action=delete")
);
assert_eq!(
covered(
"repo:*?action=create",
"repo:io.atcr.manifest?action=delete"
),
Coverage::Withheld
);
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ mod parser;
mod permission_set;
mod permissions;
pub use coverage::covers;
pub use coverage::{Coverage, coverage, covers, narrow};
pub use definitions::{
SCOPE_DEFINITIONS, ScopeCategory, ScopeDefinition, format_scope_for_display,
get_required_scopes, get_scope_definition, is_valid_scope,
+57 -29
View File
@@ -28,7 +28,22 @@ pub struct RepoScope {
pub actions: HashSet<RepoAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
impl RepoScope {
pub fn to_scope_string(&self) -> String {
let mut actions: Vec<RepoAction> = self.actions.iter().copied().collect();
actions.sort();
let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect();
format!(
"repo:{}?action={}",
self.collection.as_deref().unwrap_or("*"),
rendered.join("&action=")
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RepoAction {
Create,
@@ -37,6 +52,8 @@ pub enum RepoAction {
}
impl RepoAction {
pub const ALL: [RepoAction; 3] = [Self::Create, Self::Update, Self::Delete];
pub fn parse_str(s: &str) -> Option<Self> {
match s {
"create" => Some(Self::Create),
@@ -45,6 +62,14 @@ impl RepoAction {
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -150,6 +175,14 @@ fn parse_query_params(query: &str) -> HashMap<String, Vec<String>> {
})
}
fn parse_repo_actions(params: &HashMap<String, Vec<String>>) -> Option<HashSet<RepoAction>> {
match params.get("action") {
None => Some(RepoAction::ALL.into_iter().collect()),
Some(values) if values.is_empty() => None,
Some(values) => values.iter().map(|s| RepoAction::parse_str(s)).collect(),
}
}
pub fn parse_scope(scope: &str) -> ParsedScope {
match scope {
"atproto" => return ParsedScope::Atproto,
@@ -169,20 +202,9 @@ pub fn parse_scope(scope: &str) -> ParsedScope {
Some(rest.to_string())
};
let actions: HashSet<RepoAction> = params
.get("action")
.map(|action_values| {
action_values
.iter()
.filter_map(|s| RepoAction::parse_str(s))
.collect()
})
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
.unwrap_or_else(|| {
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
.into_iter()
.collect()
});
let Some(actions) = parse_repo_actions(&params) else {
return ParsedScope::Unknown(scope.to_string());
};
return ParsedScope::Repo(RepoScope {
collection,
@@ -191,20 +213,10 @@ pub fn parse_scope(scope: &str) -> ParsedScope {
}
if base == "repo" {
let actions: HashSet<RepoAction> = params
.get("action")
.map(|action_values| {
action_values
.iter()
.filter_map(|s| RepoAction::parse_str(s))
.collect()
})
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
.unwrap_or_else(|| {
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
.into_iter()
.collect()
});
let Some(actions) = parse_repo_actions(&params) else {
return ParsedScope::Unknown(scope.to_string());
};
return ParsedScope::Repo(RepoScope {
collection: None,
actions,
@@ -340,6 +352,22 @@ mod tests {
}
}
#[test]
fn test_parse_repo_unrecognized_action_is_not_a_repo_scope() {
assert!(matches!(
parse_scope("repo:app.bsky.feed.post?action=read"),
ParsedScope::Unknown(_)
));
assert!(matches!(
parse_scope("repo:app.bsky.feed.post?action="),
ParsedScope::Unknown(_)
));
assert!(matches!(
parse_scope("repo?action=read"),
ParsedScope::Unknown(_)
));
}
#[test]
fn test_parse_blob_wildcard() {
let scope = parse_scope("blob:*/*");
+187 -23
View File
@@ -1,9 +1,10 @@
use crate::parser::RepoAction;
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::debug;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use tracing::{debug, warn};
use tranquil_types::{Did, Nsid};
#[derive(Debug, thiserror::Error)]
@@ -332,33 +333,49 @@ fn is_under_authority(target_nsid: &str, authority: &str) -> bool {
.is_some_and(|c| c == '.')
}
const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"];
fn parse_permission_actions(actions: Option<&Vec<String>>) -> Option<BTreeSet<RepoAction>> {
match actions {
None => Some(RepoAction::ALL.into_iter().collect()),
Some(values) => values
.iter()
.map(|value| {
let parsed = RepoAction::parse_str(value);
if parsed.is_none() {
warn!(
action = %value,
"skipping permission entry with unrecognized repo action"
);
}
parsed
})
.collect(),
}
}
fn build_expanded_scopes(
permissions: &[PermissionEntry],
default_aud: Option<&str>,
namespace_authority: &str,
) -> String {
let mut scopes: Vec<String> = Vec::new();
let mut ungrouped_repo_scopes: BTreeMap<String, BTreeSet<RepoAction>> = BTreeMap::new();
let mut rpc_scopes: Vec<String> = Vec::new();
permissions
.iter()
.for_each(|perm| match perm.resource.as_str() {
"repo" => {
if let Some(collections) = &perm.collection {
let actions: Vec<&str> = perm
.action
.as_ref()
.map(|a| a.iter().map(String::as_str).collect())
.unwrap_or_else(|| DEFAULT_ACTIONS.to_vec());
if let Some(collections) = &perm.collection
&& let Some(actions) = parse_permission_actions(perm.action.as_ref())
&& !actions.is_empty()
{
collections
.iter()
.filter(|coll| is_under_authority(coll, namespace_authority))
.for_each(|coll| {
actions.iter().for_each(|action| {
scopes.push(format!("repo:{}?action={}", coll, action));
});
ungrouped_repo_scopes
.entry(coll.to_string())
.or_default()
.extend(actions.iter().copied());
});
}
}
@@ -373,14 +390,31 @@ fn build_expanded_scopes(
Some(aud) => format!("rpc:{}?aud={}", lxm, aud),
None => format!("rpc:{}", lxm),
};
scopes.push(scope);
if !rpc_scopes.contains(&scope) {
rpc_scopes.push(scope);
}
});
}
}
_ => {}
});
scopes.join(" ")
let grouped_repo_scopes: Vec<String> = ungrouped_repo_scopes
.iter()
.map(|(repo, actions)| {
let rendered: Vec<&str> = actions.iter().map(RepoAction::as_str).collect();
format!("repo:{}?action={}", repo, rendered.join("&action="))
})
.collect();
let combined_repo_scopes = grouped_repo_scopes.join(" ");
let combined_rpc_scopes = rpc_scopes.join(" ");
format!("{} {}", combined_repo_scopes, combined_rpc_scopes)
.trim()
.to_string()
}
#[cfg(test)]
@@ -459,10 +493,11 @@ mod tests {
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
assert!(expanded.contains("repo:io.atcr.sailor.star?action=create"));
assert!(!expanded.contains("app.bsky.feed.post"));
assert_eq!(
expanded,
"repo:io.atcr.manifest?action=create&action=delete \
repo:io.atcr.sailor.star?action=create&action=delete"
);
}
#[test]
@@ -476,9 +511,138 @@ mod tests {
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
assert!(expanded.contains("repo:io.atcr.manifest?action=update"));
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
assert_eq!(
expanded,
"repo:io.atcr.manifest?action=create&action=update&action=delete"
);
}
#[test]
fn test_build_expanded_scopes_repo_omitted_action_grants_all() {
let permissions = vec![PermissionEntry {
resource: "repo".to_string(),
action: None,
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert_eq!(
expanded, "repo:io.atcr.manifest?action=create&action=update&action=delete",
"an omitted action list means all actions"
);
}
#[test]
fn test_build_expanded_scopes_repo_empty_action_list_skips_entry() {
let permissions = vec![PermissionEntry {
resource: "repo".to_string(),
action: Some(vec![]),
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(
expanded.is_empty(),
"an explicitly empty action list is invalid, so the entry is skipped rather \
than expanded to all actions or emitted as a bare `?action=`, got: {expanded}"
);
}
#[test]
fn test_build_expanded_scopes_is_deterministic() {
let permissions = vec![
PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["create".to_string()]),
collection: Some(vec![
"io.atcr.sailor.star".to_string(),
"io.atcr.manifest".to_string(),
"io.atcr.blob".to_string(),
]),
lxm: None,
aud: None,
},
PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec![
"io.atcr.getManifest".to_string(),
"io.atcr.listTags".to_string(),
]),
aud: Some("*".to_string()),
},
];
let first = build_expanded_scopes(&permissions, None, "io.atcr");
assert_eq!(
first,
"repo:io.atcr.blob?action=create repo:io.atcr.manifest?action=create \
repo:io.atcr.sailor.star?action=create \
rpc:io.atcr.getManifest?aud=* rpc:io.atcr.listTags?aud=*"
);
for _ in 0..16 {
assert_eq!(build_expanded_scopes(&permissions, None, "io.atcr"), first);
}
}
#[test]
fn test_build_expanded_scopes_dedupes_and_canonicalizes_actions() {
let permissions = vec![
PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["delete".to_string(), "create".to_string()]),
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
},
PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["create".to_string(), "update".to_string()]),
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
},
PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec![
"io.atcr.getManifest".to_string(),
"io.atcr.getManifest".to_string(),
]),
aud: Some("*".to_string()),
},
];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert_eq!(
expanded,
"repo:io.atcr.manifest?action=create&action=update&action=delete \
rpc:io.atcr.getManifest?aud=*"
);
}
#[test]
fn test_build_expanded_scopes_repo_unrecognized_action_skips_entry() {
let permissions = vec![PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["read".to_string()]),
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(
expanded.is_empty(),
"an unrecognized repo action must not expand to all actions, got: {expanded}"
);
}
#[test]
+28 -13
View File
@@ -177,8 +177,6 @@ impl ScopePermissions {
return Ok(());
}
let aud_base = aud.split('#').next().unwrap_or(aud);
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
let lxm_matches = match &rpc_scope.lxm {
None => true,
@@ -193,10 +191,7 @@ impl ScopePermissions {
let aud_matches = match &rpc_scope.aud {
None => true,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => {
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
scope_aud_base == aud_base
}
Some(scope_aud) => scope_aud == aud,
};
lxm_matches && aud_matches
@@ -558,27 +553,47 @@ mod tests {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#other_service",
&c("app.bsky.feed.getAuthorFeed")
));
assert!(
!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")),
"a scope naming one service must not cover the whole DID"
);
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
),
"a scope naming one service must not cover a sibling service on the same DID"
);
assert!(!perms.allows_rpc("did:web:other.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(!perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getTimeline")));
}
#[test]
fn test_rpc_scope_without_fragment_matches_with_fragment() {
fn test_rpc_scope_without_fragment_does_not_cover_service_ids() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(
!perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
&c("app.bsky.feed.getAuthorFeed")
),
"the audience is compared verbatim, so a bare DID grants nothing to its services"
);
}
#[test]
fn test_rpc_scope_aud_wildcard_covers_service_ids() {
let perms =
ScopePermissions::from_scope_string(Some("rpc:app.bsky.feed.getAuthorFeed?aud=*"));
assert!(perms.allows_rpc("did:web:api.bsky.app", &c("app.bsky.feed.getAuthorFeed")));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
"did:web:api.bsky.app#atproto_labeler",
&c("app.bsky.feed.getAuthorFeed")
));
}
+4 -1
View File
@@ -41,8 +41,11 @@ tracing-subscriber = { workspace = true }
rcgen = { workspace = true }
[features]
default = ["frontend", "s3", "valkey"]
default = ["bsky", "frontend", "postgres", "s3", "valkey"]
bsky = ["bsky-support", "tranquil-pds/bsky", "tranquil-api/bsky"]
bsky-support = ["tranquil-pds/bsky-support", "tranquil-api/bsky-support"]
frontend = ["tranquil-pds/frontend"]
postgres = ["tranquil-pds/postgres"]
s3 = ["tranquil-pds/s3"]
valkey = ["tranquil-pds/valkey"]
native-tls-roots = ["tranquil-pds/native-tls-roots"]
+1 -3
View File
@@ -17,13 +17,11 @@ tracing = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
url = "2.5"
url = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tranquil-signal = { path = ".", features = ["fjall-store"] }
rand = "0.9"
tempfile = "3"
-1
View File
@@ -5,7 +5,6 @@ edition.workspace = true
license.workspace = true
[features]
default = []
s3 = ["dep:aws-config", "dep:aws-sdk-s3"]
[dependencies]
+1 -2
View File
@@ -59,8 +59,7 @@ tempfile = "3"
futures = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt-multi-thread", "macros", "time"] }
jacquard-common = { workspace = true }
tranquil-repo = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db = { workspace = true, features = [ "postgres" ] }
sqlx = { workspace = true }
k256 = { workspace = true }
rand = { workspace = true }
+8 -5
View File
@@ -4,18 +4,21 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
default = ["sqlx"]
sqlx = ["dep:sqlx"]
[dependencies]
base64 = { workspace = true }
chrono = { workspace = true }
cid = { workspace = true }
jacquard-common = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true, optional = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt"] }
tracing = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

Some files were not shown because too many files have changed in this diff Show More