a (hacky, wip) multi-tenant oidc-terminating reverse proxy, written in anger on top of pingora

add some docs and a readme

+277 -3
+15
LICENSE.txt
··· 1 + ISC License 2 + 3 + Copyright (c) 2026 Solly "directxman12" Ross 4 + 5 + Permission to use, copy, modify, and/or distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 + PERFORMANCE OF THIS SOFTWARE.
+202
README.md
··· 1 + # An OIDC Reverse Proxy, Written in Anger 2 + 3 + ## What is it? 4 + 5 + It's a multi-tentant non-caching tls-terminating [reverse proxy] that can 6 + terminate a hybrid of [OpenID Connect Core v1.0 with errata 2][oidc-v1] [^1] 7 + and [OAuth 2.1][oauth-v2.1] [^2]. It also generally reverse proxies HTTP 1.x, 8 + h2, websocket, and grpc traffic, as it's built (right now) on [pingora]. 9 + 10 + ## ... what? 11 + 12 + You stick in front of webapps that need either tls added or don't support 13 + checking oauth2 state themselves, and it does that for you. 14 + 15 + Think [oauth2-proxy], but with proper multitenancy support and significantly 16 + less battle-tested. 17 + 18 + ## whyyyy???? 19 + 20 + I [was](https://bsky.app/profile/directxman12.dev/post/3me6doan42k2t) [frustrated](https://bsky.app/profile/directxman12.dev/post/3merwp3aik22u) with the state of oauth2/oidc-terminating proxies. 21 + 22 + Particularly, at the time of writing, [oauth2-proxy] does not support 23 + multitenancy properly (it encourages the insecure practice of sharing 24 + client_ids if you use it for forward auth). 25 + 26 + Other things, like [caddy-security], felt overcomplicated for what i needed to 27 + do, and tbh i don't trust security tools that feel like they have everything 28 + and the the kitchen sink shoved in, unless they've been written by experts 29 + and/or extensively auditted. 30 + 31 + [caddy-security]: https://github.com/greenpau/caddy-security 32 + 33 + ## how do i use it? 34 + 35 + The configuration is done in [textproto] format [^3]. 36 + The format is in <src/config/format.proto>, and the inline docs serve as the 37 + documentation, so i'd suggest starting there. 38 + 39 + Here's a quick example config: 40 + 41 + ```textproto 42 + # bind on ipv4 43 + bind_to_tcp { 44 + addr: '0.0.0.0:8443' 45 + tls { 46 + cert_path: "/var/lib/secrets/serving.cert" 47 + key_path: "/var/lib/secrets/serving.key" 48 + } 49 + } 50 + # bind on ipv6 51 + bind_to_tcp { 52 + addr: '[::]:8443' 53 + tls { 54 + cert_path: "/var/lib/secrets/serving.cert" 55 + key_path: "/var/lib/secrets/serving.key" 56 + } 57 + } 58 + 59 + # serve these domains 60 + 61 + # serve copyparty w/ oidc termination on 62 + domains { 63 + key: "files.example.com" 64 + value: { 65 + # serve to a (single, here, but this is a `repeated` field) 66 + # uds backend, without tls 67 + uds { 68 + path: "/run/copyparty/party.sock" 69 + } 70 + # enable oidc termination on this domain 71 + oidc_auth { 72 + # standard oidc/oauth stuff 73 + discovery_url_base: "https://sso.example.com/oauth2/openid/files/" 74 + client_id: "files" 75 + 76 + # this is where we redirect after logout 77 + logout_url: "https://sso.example.com/" 78 + # put your client secret here 79 + client_secret_path: '/var/lib/secrets/files.client-secret' 80 + 81 + # these scopes are required, and sent in the request 82 + scopes { 83 + required: "profile" 84 + required: "group_names" 85 + required: "party" 86 + } 87 + # these claims are mapped to headers 88 + claims { 89 + claim_to_header { 90 + key: "group_names" 91 + value: "X-Idp-Groups" 92 + } 93 + claim_to_header { 94 + key: "name" 95 + value: "X-Idp-User" 96 + } 97 + }, 98 + } 99 + } 100 + } 101 + 102 + # serve your sso server, with oidc termination off 103 + domains { 104 + key: "sso.example.com" 105 + value: { 106 + # your sso server might have it's own tls termination too, so 107 + # connect to it with tls 108 + https { 109 + addr: "127.0.0.1:1309" 110 + } 111 + } 112 + } 113 + ``` 114 + 115 + [textproto]: https://protobuf.dev/reference/protobuf/textformat-spec/ 116 + 117 + [^3]: **Q**: wait, seriously? **A**: yes, seriously. i'm putting my money 118 + where my mouth is, so to speak, when i say that textproto is nice, actually. 119 + 120 + ## should i trust this? 121 + 122 + **_no, probably not._** 123 + 124 + consider the following points if you chose to run this: 125 + 126 + - i make no claims about it's security. 127 + 128 + - i'm not a security professional, just a dev with a hobby of writing oauth 129 + implementations in anger, apparently [^4]. 130 + 131 + - if you're often targetted by script kiddies or nation-state actors, 132 + or you're working for a company or organization, please don't use this. 133 + 134 + - it'll probably be fine for low-stress homelab situations? that's what i'm using 135 + it for. 136 + 137 + [^4]: last year i wrote (but haven't published yet) a cute little oauth server 138 + that uses exclusively passkeys from scratch. 139 + 140 + ## but does it support... 141 + 142 + <dl> 143 + <dt>...serving forward auth?</dt> 144 + <dd> 145 + 146 + no, not right now. forward auth is an [underspecified mess][forward-auth-mess], 147 + so it's unlikely that i'll add support. 148 + 149 + </dd> 150 + 151 + <dt>...authorization?</dt> 152 + <dd> 153 + 154 + nope, this was written for reverse-proxying apps that either 155 + 156 + a) can make their own auth decisions (like [copyparty]), or 157 + b) don't need to make complex auth decisions 158 + 159 + </dd> 160 + 161 + <dt>...other types of authn?</dt> 162 + <dd> 163 + 164 + nope, and it's unlikely that it will -- i prefer to keep things well-scoped. 165 + unless it's something odic/oauth2-adjacent, in which case i'd consider it. 166 + 167 + </dd> 168 + 169 + <dt>...different certs per domain</dt> 170 + <dd>not yet, but i'd like to get to it eventually</dd> 171 + 172 + </dl> 173 + 174 + For anything else, check the [issue tracker] and/or file an issue. 175 + 176 + ## omg the code is messsyyyyyy! 177 + 178 + yeah, it probably needs further refactoring. 179 + 180 + ## Contributing 181 + 182 + On the off-chance that you'd like to submit code, i'll take a look at it and 183 + review it, but i'm likely going to be picky about what new features i accept. 184 + it's probably worth filing an issue to discuss first. 185 + 186 + any code submitted should be your own, and you should understand it fully. 187 + 188 + [forward-auth-mess]: https://github.com/kanidm/kanidm/issues/2774 189 + [issue tracker]: https://tangled.org/directxman12.dev/proxy-in-anger/issues 190 + [copyparty]: https://github.com/9001/copyparty 191 + 192 + 193 + 194 + [reverse proxy]: https://en.wikipedia.org/wiki/Reverse_proxy 195 + [oidc-v1]: https://openid.net/specs/openid-connect-core-1_0.html 196 + [oauth-v2.1]: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1 197 + [pingora]: https://github.com/cloudflare/pingora 198 + [oauth2-proxy]: https://github.com/oauth2-proxy/oauth2-proxy 199 + 200 + 201 + [^1]: the uuuh... reasonable parts? yeesh, there's a lotta junk in oidc core v1 202 + [^2]: specifically, the authorization code grant flow
+60 -3
src/config/format.proto
··· 3 3 package config.format; 4 4 5 5 // the root config 6 + // 7 + // each of these fields will show up at the root of your config file 8 + // 9 + // at the minimum, you'll need at least 1 `domains` entry to define your 10 + // sites, and one `bind_to_tcp` entry to define how the proxy itself 11 + // serves content. 6 12 message Config { 7 13 // the domains to serve 8 14 map<string, Domain> domains = 1; ··· 11 17 repeated TCPBinding bind_to_tcp = 2; 12 18 13 19 // lower-level pingora config 20 + // 21 + // (you probably don't need to touch this) 14 22 Pingora pingora = 3; 15 23 } 16 24 25 + // a single, served domain 26 + // 27 + // you'll want at least 1 backend (`https`, `http`, or `uds`), 28 + // but you can repeat each one to define multiple backends 29 + // and load-balance between them, and you can (if you really need 30 + // to) mix and match types. 31 + // 32 + // you may also want an (optional) `oidc_auth` configuration to turn on 33 + // authentication-termination 34 + // 35 + // you can also use `manage_headers` to inject things like `X-Forwarded-For`, 36 + // or clear headers that you don't want going to your backend. 17 37 message Domain { 18 38 // require oidc auth if this is set 19 39 optional OIDC oidc_auth = 1; ··· 34 54 TLS_MODE_UNSAFE_ALLOW_HTTP = 1; 35 55 } 36 56 57 + // allow disabling TLS termination, for testing 37 58 TLSMode tls_mode = 5; 38 59 39 60 // set or clear headers on the backend request 40 61 ManageHeaders manage_headers = 6; 41 62 } 42 63 64 + // configure oidc/oauth v2.1 termination 43 65 message OIDC { 44 66 // the base oidc discovery url, without the `.well-known/openid-configuration` part 45 67 // 46 68 // per [OIDC Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) 69 + // and [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). 47 70 string discovery_url_base = 1; 48 71 72 + // your oauth client id, from your oauth provider 49 73 string client_id = 2; 74 + // your oauth client secret, from your oauth provider 50 75 string client_secret_path = 3; 51 76 77 + // a set of scopes you wish to ask the sever for 78 + // 79 + // (`openid` will always be automatically included) 52 80 Scopes scopes = 4; 81 + // map returned "claims" (pieces of user information) 82 + // to header in the backend request. 83 + // 84 + // for example, you can use this to tell backend services the name of the 85 + // authenticated user. 53 86 Claims claims = 5; 54 87 55 88 // per oidc core v1-with-errata-2§3.1.3.7 point 6, we _may_ skip validation ··· 57 90 // case. some folks may want to be extra paranoid, but generally you either 58 91 // trust tls, or you can't trust discovery, and thus can't trust the jwks info, 59 92 // so default this to false. 93 + // 94 + // generally, you can leave this off. 60 95 bool validate_with_jwk = 6; 61 96 62 97 // where to redirect to on logout 63 98 string logout_url = 7; 64 99 } 65 100 101 + // scopes to ask the server for 66 102 message Scopes { 67 - repeated string optional = 1; 103 + // the scopes you need from the server for your claims map to work, or that 104 + // you want to request to ensure that the user is authorized to continue to 105 + // your site. 68 106 repeated string required = 2; 107 + 108 + // optional scopes, not requested from the server 109 + // 110 + // not currently used 111 + repeated string optional = 1; 69 112 } 70 113 71 114 // information on how to process returned claims ··· 110 153 } 111 154 112 155 // equivalent to [`pingora::server::configuration::Config`] 156 + // 157 + // See [pingora](https://docs.rs/pingora/latest/pingora/server/configuration/struct.ServerConf.html) 158 + // for details on what these do 113 159 message Pingora { 114 160 uint64 version = 1; 115 161 bool daemon = 2; ··· 135 181 136 182 } 137 183 184 + // bind to a tcp port 185 + // 186 + // this configures which ports you'll serve https & grpc traffic on, 187 + // and how you'll terminate tls. 188 + // 189 + // you'll want to chose an address, as well as point at your tls certs. 138 190 message TCPBinding { 139 - // configure used tls settings 191 + // configure tls settings 140 192 message TLS { 141 193 // path to the (public) tls certificate, with all intermediate certificates (fullchain.pem for most acme clients) 142 194 string cert_path = 1; ··· 146 198 147 199 // host an port to bind to 148 200 string addr = 1; 149 - // tls, if desired 201 + 202 + // enable tls 203 + // 204 + // you should _always_ have this, unless you're testing 205 + // 206 + // oidc will not work without it 150 207 optional TLS tls = 2; 151 208 152 209 // TODO(feature): surface tcp options from pingora