mirror of
https://github.com/Defelo/nginx-oidc.git
synced 2025-05-12 13:02:48 +00:00
init
This commit is contained in:
commit
abb8ee311f
35 changed files with 17604 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
|||
use flake
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
Cargo.nix linguist-vendored
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
*_secret
|
||||
socket
|
||||
config.dev.yml
|
||||
.nixos-test-history
|
3083
Cargo.lock
generated
Normal file
3083
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
|
@ -0,0 +1,37 @@
|
|||
[package]
|
||||
name = "nginx-oidc"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
self_named_module_files = "warn"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
unwrap_used = "warn"
|
||||
expect_used = "warn"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.98", default-features = false }
|
||||
axum = { version = "0.8.3", default-features = false, features = ["http1", "http2", "tokio", "query"] }
|
||||
axum-extra = { version = "0.10.1", default-features = false, features = ["cookie-private", "cookie-key-expansion"] }
|
||||
clap = { version = "4.5.36", default-features = true, features = ["derive"] }
|
||||
clap_complete = { version = "4.5.47", default-features = false, features = ["unstable-dynamic"] }
|
||||
config = { version = "0.15.11", default-features = false, features = ["yaml"] }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
openidconnect = { version = "4.0.0", default-features = false, features = [
|
||||
"reqwest",
|
||||
"rustls-tls",
|
||||
"timing-resistant-secret-traits",
|
||||
] }
|
||||
serde = { version = "1.0.219", default-features = false, features = ["derive"] }
|
||||
serde_json = { version = "1.0.140", default-features = false, features = ["std"] }
|
||||
tokio = { version = "1.44.2", default-features = false, features = ["rt-multi-thread", "macros"] }
|
||||
tracing = { version = "0.1.41", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.19", default-features = false, features = ["ansi", "env-filter", "fmt"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { version = "1.4.1", default-features = false, features = ["std"] }
|
||||
tempfile = { version = "3.19.1", default-features = false }
|
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Felix Bargfeldt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
56
README.md
Normal file
56
README.md
Normal file
|
@ -0,0 +1,56 @@
|
|||
# nginx-oidc
|
||||
|
||||
[OpenID Connect (OIDC)](https://openid.net/developers/how-connect-works/) Integration for [nginx](https://nginx.org/) via [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html)
|
||||
|
||||
nginx-oidc enables single sign-on (SSO) via any OIDC provider (e.g. [Kanidm](https://github.com/kanidm/kanidm), [Authentik](https://goauthentik.io/) or [Keycloak](https://www.keycloak.org/)) for any nginx site using the [`auth_request` module](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html).
|
||||
It is primarily designed to run on [NixOS](https://nixos.org/), but should also work on any other Linux distribution.
|
||||
|
||||
## Features
|
||||
- Stateless, easy to set up
|
||||
- Supports public and confidential OIDC clients
|
||||
- Enforces Proof Key for Code Exchange (PKCE)
|
||||
- Optional role-based access control
|
||||
- Bind session to user's ip address
|
||||
- Send headers containing information about the authenticated user to the proxied service using [`proxy_set_header`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header)
|
||||
- NixOS module
|
||||
|
||||
## NixOS Module Documentation
|
||||
See [nixos-options.md](./nixos-options.md)
|
||||
|
||||
## Setup Instructions (NixOS)
|
||||
|
||||
1. Add this repository as an input to your `flake.nix`:
|
||||
```nix
|
||||
{
|
||||
inputs.nginx-oidc.url = "github:Defelo/nginx-oidc";
|
||||
}
|
||||
```
|
||||
2. Add the module to your NixOS configuration:
|
||||
```nix
|
||||
{
|
||||
imports = [nginx-oidc.nixosModules.default];
|
||||
}
|
||||
```
|
||||
|
||||
### Server
|
||||
The nginx-oidc server only needs to be reachable by nginx.
|
||||
It is recommended to run both nginx and nginx-oidc on the same host and let nginx-oidc listen on a unix socket (configured automatically by the NixOS module if not explicitly changed).
|
||||
|
||||
1. Enable the nginx-oidc server by setting `services.nginx.oidc.enable = true;`.
|
||||
2. (recommended) Generate a random cookie secret file (e.g. using `head -c 64 /dev/urandom`) and set `services.nginx.oidc.settings.cookie_secret_path` to the absolute path of this file. If this option is not set, a random secret is generated on each start, invalidating any previously issued auth/session cookies.
|
||||
3. Configure your OIDC client(s) via the `services.nginx.oidc.settings.clients` option. You need to specify at least the `issuer` URL. The `client_id` defaults to the client name (attribute name). If you want to configure a confidential client, you need to specify the `client_secret_path`. Omit this option in case of a public client.
|
||||
|
||||
### Nginx Integration
|
||||
Make sure your nginx is compiled with the `--with-http_auth_request_module` configure flag to include the [`ngx_http_auth_request_module`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html).
|
||||
[On NixOS this flag is already set](https://github.com/NixOS/nixpkgs/blob/472b4108d146d56eafdedaa30bb9376c4d139f89/pkgs/servers/http/nginx/generic.nix#L130).
|
||||
|
||||
1. Enable nginx-oidc for all locations you want to restrict by setting `services.nginx.virtualHosts.<name>.locations.<name>.oidc.enable = true;`
|
||||
2. Set the `clientName` option to the name of the client configured in the nginx-oidc server you want to use. Defaults to the name of the virtual host if unset.
|
||||
3. (optional) Set the `role` option to allow access to only users with a specific role.
|
||||
4. If you set up the nginx-oidc server on a different host (not recommended), you need to set the `nginxOidcUrl` option accordingly.
|
||||
|
||||
## How it works
|
||||
1. When a user tries to access a restricted location, nginx sends an HTTP request to `<nginx-oidc>/auth/<client>` which includes the session cookie (if set).
|
||||
2. If the session cookie is valid and the user is authorized to access this location, nginx-oidc returns a `200 OK` and access is granted. Otherwise a `401 Unauthorized` is returned including a signed and encrypted auth cookie which contains the URL the user tried to access and the OAuth2 state and an `X-Auth-Redirect` header which nginx then translates into a redirect to the auth URL of the OIDC provider.
|
||||
3. After logging in to the OIDC provider the user is redirected to the callback location on the same virtualHost as the restricted location which is proxied to `<nginx-oidc>/callback/<client>`. nginx-oidc then completes the OIDC flow by exchanging the authorization code for an access token and fetches the user's information from the OIDC provider. A signed and encrypted session cookie is set and the user is redirected back to the URL they originally came from.
|
||||
4. If the session cookie has expired, nginx-oidc tries to refetch the user's information using the access/refresh tokens. If this fails, the user is redirected to the OIDC provider to reauthenticate.
|
14
config.yml
Normal file
14
config.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
# cookie_secret_path: "PATH_TO_COOKIE_SECRET"
|
||||
ca_certs: []
|
||||
# clients:
|
||||
# CLIENT_ID:
|
||||
# issuer: "https://id.example.com/oauth2/openid/CLIENT_ID"
|
||||
# client_id: "CLIENT_ID"
|
||||
# client_secret_path: "PATH_TO_CLIENT_SECRET"
|
||||
# scopes: ["openid", "email"]
|
||||
# roles_claim: "roles"
|
||||
# auth_cookie_ttl_secs: 600
|
||||
# session_cookie_ttl_secs: 60
|
||||
# keep_access_token: true
|
||||
# keep_refresh_token: true
|
||||
# real_ip_header: "X-Real-Ip"
|
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1744463964,
|
||||
"narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
83
flake.nix
Normal file
83
flake.nix
Normal file
|
@ -0,0 +1,83 @@
|
|||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs, ... }:
|
||||
|
||||
let
|
||||
inherit (nixpkgs) lib;
|
||||
|
||||
eachDefaultSystem = lib.genAttrs [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
in
|
||||
|
||||
{
|
||||
packages = eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = self.packages.${system}.nginx-oidc;
|
||||
|
||||
nginx-oidc = pkgs.callPackage ./nix/package.nix { };
|
||||
|
||||
test = pkgs.callPackage ./nix/test.nix { inherit self; };
|
||||
|
||||
docs = pkgs.callPackage ./nix/docs.nix { inherit self; };
|
||||
|
||||
generate = pkgs.writeShellScriptBin "generate" ''
|
||||
cd "$(${lib.getExe pkgs.git} rev-parse --show-toplevel)"
|
||||
|
||||
${lib.getExe pkgs.crate2nix} generate
|
||||
|
||||
cat ${self.packages.${system}.docs} > nixos-options.md
|
||||
'';
|
||||
|
||||
checks = pkgs.linkFarm "checks" (
|
||||
lib.removeAttrs self.packages.${system} [ "checks" ]
|
||||
// {
|
||||
devShells = pkgs.linkFarm "devShells" self.devShells.${system};
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = import ./nix/module.nix { inherit lib self; };
|
||||
|
||||
devShells = eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [ pkgs.crate2nix ];
|
||||
|
||||
env = {
|
||||
RUST_LOG = "info,nginx_oidc=trace";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
formatter = eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
pkgs.treefmt.withConfig {
|
||||
settings = [
|
||||
./treefmt.nix
|
||||
{ _module.args = { inherit pkgs; }; }
|
||||
];
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
52
nix/docs.nix
Normal file
52
nix/docs.nix
Normal file
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
lib,
|
||||
pkgs,
|
||||
self,
|
||||
}:
|
||||
|
||||
let
|
||||
eval = lib.evalModules {
|
||||
modules = [
|
||||
{
|
||||
config._module.check = false;
|
||||
config._module.args = { inherit pkgs; };
|
||||
options._module.args = lib.mkOption { internal = true; };
|
||||
}
|
||||
(import ./module.nix { inherit lib self; })
|
||||
];
|
||||
};
|
||||
|
||||
hideUpstreamOption =
|
||||
opt:
|
||||
if
|
||||
lib.elem opt.name [
|
||||
"services.nginx.virtualHosts"
|
||||
"services.nginx.virtualHosts.<name>.locations"
|
||||
]
|
||||
then
|
||||
opt // { visible = false; }
|
||||
else
|
||||
opt;
|
||||
|
||||
removeTrailingNewlineInLiteralExpression =
|
||||
opt:
|
||||
if opt.default._type or null == "literalExpression" then
|
||||
opt // { default = lib.literalExpression (lib.removeSuffix "\n" opt.default.text); }
|
||||
else
|
||||
opt;
|
||||
|
||||
docs =
|
||||
(pkgs.nixosOptionsDoc {
|
||||
inherit (eval) options;
|
||||
transformOptions = lib.flip lib.pipe [
|
||||
hideUpstreamOption
|
||||
removeTrailingNewlineInLiteralExpression
|
||||
];
|
||||
}).optionsCommonMark;
|
||||
in
|
||||
|
||||
pkgs.runCommand "docs" { } ''
|
||||
${lib.getExe pkgs.gnused} -E \
|
||||
's|\[${self}/(.*)\]\(.*\)|[\1](https://github.com/Defelo/nginx-oidc/blob/develop/\1)|' \
|
||||
${docs} > $out
|
||||
''
|
433
nix/module.nix
Normal file
433
nix/module.nix
Normal file
|
@ -0,0 +1,433 @@
|
|||
{ lib, self }:
|
||||
|
||||
let
|
||||
# hasn't been backported to 24.11
|
||||
# TODO: remove after 25.05 has been released
|
||||
inherit (lib) concatMapAttrsStringSep;
|
||||
in
|
||||
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
settingsFormat = pkgs.formats.yaml { };
|
||||
cfg = config.services.nginx.oidc;
|
||||
|
||||
listenAddressType = lib.head (lib.attrNames cfg.listenAddress);
|
||||
listenAddressTcp =
|
||||
let
|
||||
inherit (cfg.listenAddress.tcp) host port;
|
||||
hostWrapped = if lib.hasInfix ":" host then "[${host}]" else host;
|
||||
in
|
||||
"${lib.escapeShellArg hostWrapped}:${toString port}";
|
||||
listenAddressArg =
|
||||
{
|
||||
tcp = "--tcp ${listenAddressTcp}";
|
||||
unix = "--unix ${lib.escapeShellArg cfg.listenAddress.unix.path}";
|
||||
}
|
||||
.${listenAddressType};
|
||||
|
||||
settings = cfg.settings // {
|
||||
cookie_secret_path = mkCred cfg.settings.cookie_secret_path;
|
||||
clients = lib.mapAttrs (
|
||||
_: client: client // { client_secret_path = mkCred client.client_secret_path; }
|
||||
) cfg.settings.clients;
|
||||
};
|
||||
configFiles = [
|
||||
(settingsFormat.generate "config.yaml" settings)
|
||||
] ++ map mkCred cfg.extraConfigFiles;
|
||||
configFileArgs = map (path: "--config ${lib.escapeShellArg path}") configFiles;
|
||||
|
||||
hash = builtins.hashString "sha256";
|
||||
mkCred = x: if x != null then "/run/credentials/nginx-oidc.service/${hash x}" else x;
|
||||
in
|
||||
|
||||
{
|
||||
meta.maintainers = with lib.maintainers; [ defelo ];
|
||||
_file = ./module.nix;
|
||||
|
||||
options.services.nginx.oidc = {
|
||||
enable = lib.mkEnableOption "nginx-oidc";
|
||||
|
||||
package = lib.mkPackageOption self.packages.${pkgs.system} "nginx-oidc" {
|
||||
pkgsText = "nginx-oidc.packages.\${system}";
|
||||
};
|
||||
|
||||
logLevel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Log level of the nginx-oidc server. See <https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives> for more information.";
|
||||
default = "info";
|
||||
};
|
||||
|
||||
extraConfigFiles = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.path;
|
||||
description = "Extra configuration files to include.";
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.attrTag {
|
||||
tcp = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
_file = ./module.nix;
|
||||
options = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Host on which the server should listen.";
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
description = "Port on which the server should listen.";
|
||||
};
|
||||
};
|
||||
};
|
||||
description = "Listen on a TCP socket.";
|
||||
};
|
||||
unix = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
_file = ./module.nix;
|
||||
options = {
|
||||
path = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Path of the unix socket on which the server should listen.";
|
||||
};
|
||||
};
|
||||
};
|
||||
description = "Listen on a unix socket.";
|
||||
};
|
||||
};
|
||||
description = "Where the server should listen for incoming connections.";
|
||||
default.unix.path = "/run/nginx-oidc/http.socket";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
freeformType = settingsFormat.type;
|
||||
options = {
|
||||
cookie_secret_path = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
description = "Path of the file containing the secret used to sign and encrypt session cookies. If unset, a random secret is generated on each run.";
|
||||
default = null;
|
||||
};
|
||||
|
||||
ca_certs = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.path;
|
||||
description = "List of paths of additional CA certificates to trust.";
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
clients = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }:
|
||||
{
|
||||
freeformType = settingsFormat.type;
|
||||
options = {
|
||||
issuer = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Issuer URL of the OIDC client (without the `/.well-known/openid-configuration` suffix)";
|
||||
};
|
||||
|
||||
client_id = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Client ID of the OIDC client. Defaults to the attribute name.";
|
||||
};
|
||||
|
||||
client_secret_path = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
description = "Path of the file containing the client secret of the OIDC client. Set to `null` for public clients.";
|
||||
default = null;
|
||||
};
|
||||
|
||||
scopes = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Scopes to request from the OIDC provider.";
|
||||
default = [
|
||||
"openid"
|
||||
"email"
|
||||
];
|
||||
};
|
||||
|
||||
roles_claim = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "OIDC claim which contains a list of the user's roles.";
|
||||
default = "roles";
|
||||
};
|
||||
|
||||
auth_cookie_ttl_secs = lib.mkOption {
|
||||
type = lib.types.ints.unsigned;
|
||||
description = "Number of seconds the auth cookie is valid. This cookie is used to remember the original URL and authentication state when the user is redirected to the OIDC provider.";
|
||||
default = 600;
|
||||
};
|
||||
|
||||
session_cookie_ttl_secs = lib.mkOption {
|
||||
type = lib.types.ints.unsigned;
|
||||
description = "Number of seconds the session cookie is valid. After the session cookie has expired nginx-oidc first tries to refetch the user's information by using the access and refresh tokens. The user is only redirected to the OIDC provider if these attempts do not succeed.";
|
||||
default = 60;
|
||||
};
|
||||
|
||||
keep_access_token = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to remember the OIDC access token after a successful authorization.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
keep_refresh_token = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
description = "Whether to remember the OIDC refresh token after a successful authorization.";
|
||||
default = true;
|
||||
};
|
||||
|
||||
real_ip_header = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header which contains the user's real ip. If unset, the session is not bound to the user's ip address.";
|
||||
default = "X-Real-Ip";
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
client_id = lib.mkDefault name;
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
description = "OIDC clients";
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
description = "Configuration of the nginx-oidc server.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
systemd.services.nginx-oidc = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
|
||||
environment.RUST_LOG = cfg.logLevel;
|
||||
|
||||
serviceConfig = {
|
||||
User = "nginx-oidc";
|
||||
Group = "nginx-oidc";
|
||||
DynamicUser = true;
|
||||
RuntimeDirectory = "nginx-oidc";
|
||||
|
||||
LoadCredential =
|
||||
let
|
||||
clientSecrets = lib.mapAttrsToList (_: client: client.client_secret_path) cfg.settings.clients;
|
||||
secrets = lib.filter (x: x != null) (
|
||||
[ cfg.settings.cookie_secret_path ] ++ clientSecrets ++ cfg.extraConfigFiles
|
||||
);
|
||||
in
|
||||
map (s: "${hash s}:${s}") secrets;
|
||||
|
||||
ExecStart = "${lib.getExe cfg.package} serve ${listenAddressArg} ${toString configFileArgs}";
|
||||
|
||||
ExecStartPost =
|
||||
lib.mkIf (config.services.nginx.enable && cfg.listenAddress ? unix)
|
||||
"+${pkgs.writeShellScript "nginx-oidc-post-start" ''
|
||||
until [[ -e ${lib.escapeShellArg cfg.listenAddress.unix.path} ]]; do sleep 1; done
|
||||
${lib.getExe' pkgs.acl "setfacl"} -m u:${lib.escapeShellArg config.services.nginx.user}:rw ${lib.escapeShellArg cfg.listenAddress.unix.path}
|
||||
''}";
|
||||
|
||||
# Hardening
|
||||
AmbientCapabilities = [ "" ];
|
||||
CapabilityBoundingSet = [ "" ];
|
||||
DevicePolicy = "closed";
|
||||
LockPersonality = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
PrivateTmp = true;
|
||||
PrivateUsers = true;
|
||||
ProcSubset = "pid";
|
||||
ProtectClock = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectHostname = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectProc = "invisible";
|
||||
ProtectSystem = "strict";
|
||||
RemoveIPC = true;
|
||||
RestrictAddressFamilies = [ "AF_INET AF_INET6 AF_UNIX" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
SocketBindAllow = lib.mkIf (cfg.listenAddress ? tcp) "tcp:${toString cfg.listenAddress.tcp.port}";
|
||||
SocketBindDeny = "any";
|
||||
SystemCallArchitectures = "native";
|
||||
SystemCallFilter = [
|
||||
"@system-service"
|
||||
"~@privileged"
|
||||
"~@resources"
|
||||
];
|
||||
UMask = "0077";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
options.services.nginx.virtualHosts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }@vhost:
|
||||
let
|
||||
prefixFor = location: "_nginx_oidc_${hash "${vhost.name} ${location}"}";
|
||||
in
|
||||
{
|
||||
options.locations = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule (
|
||||
{ name, ... }@location:
|
||||
let
|
||||
prefix = prefixFor location.name;
|
||||
in
|
||||
{
|
||||
options.oidc = {
|
||||
enable = lib.mkEnableOption "OIDC auth for this location";
|
||||
|
||||
nginxOidcUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Base URL of the nginx-oidc server which nginx should use.";
|
||||
defaultText = lib.literalExpression ''
|
||||
{
|
||||
tcp = "http://''${listenAddressTcp}";
|
||||
unix = "http://unix:''${listenAddressUnix}:";
|
||||
}.''${listenAddressType}
|
||||
'';
|
||||
};
|
||||
|
||||
clientName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Name of the client configured in nginx-oidc. Defaults to the `virtualHosts` attribute name.";
|
||||
};
|
||||
|
||||
role = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Name of a role a user has to have in order to be granted access.";
|
||||
default = null;
|
||||
};
|
||||
|
||||
callbackPath = lib.mkOption {
|
||||
type = lib.types.strMatching "^/.*$";
|
||||
description = "Path for the OAuth2 redirect URL.";
|
||||
defaultText = lib.literalExpression ''
|
||||
"/''${prefix}/callback"
|
||||
'';
|
||||
};
|
||||
|
||||
headers = {
|
||||
sub = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header to set via `proxy_set_header` containing the `subject` claim (unique user identifier).";
|
||||
default = "X-Auth-Sub";
|
||||
};
|
||||
|
||||
name = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header to set via `proxy_set_header` containing the `name` claim (display name of the user).";
|
||||
default = "X-Auth-Name";
|
||||
};
|
||||
|
||||
username = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header to set via `proxy_set_header` containing the `preferred_username` claim.";
|
||||
default = "X-Auth-Username";
|
||||
};
|
||||
|
||||
email = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header to set via `proxy_set_header` containing the `email` claim.";
|
||||
default = "X-Auth-Email";
|
||||
};
|
||||
|
||||
roles = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
description = "Header to set via `proxy_set_header` containing the roles of the user.";
|
||||
default = "X-Auth-Roles";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = {
|
||||
oidc = {
|
||||
nginxOidcUrl = lib.mkIf cfg.enable (
|
||||
lib.mkDefault
|
||||
{
|
||||
tcp = "http://${listenAddressTcp}";
|
||||
unix = "http://unix:${cfg.listenAddress.unix.path}:";
|
||||
}
|
||||
.${listenAddressType}
|
||||
);
|
||||
|
||||
clientName = lib.mkDefault vhost.name;
|
||||
|
||||
callbackPath = lib.mkDefault "/${prefix}/callback";
|
||||
};
|
||||
|
||||
extraConfig = lib.mkIf location.config.oidc.enable ''
|
||||
auth_request .${prefix}_auth;
|
||||
auth_request_set $auth_redirect $upstream_http_x_auth_redirect;
|
||||
auth_request_set $auth_cookie $upstream_http_set_cookie;
|
||||
error_page 401 =307 $auth_redirect;
|
||||
more_set_headers "Set-Cookie: $auth_cookie";
|
||||
|
||||
auth_request_set $auth_sub $upstream_http_x_auth_sub;
|
||||
auth_request_set $auth_name $upstream_http_x_auth_name;
|
||||
auth_request_set $auth_username $upstream_http_x_auth_username;
|
||||
auth_request_set $auth_email $upstream_http_x_auth_email;
|
||||
auth_request_set $auth_roles $upstream_http_x_auth_roles;
|
||||
|
||||
${concatMapAttrsStringSep "\n" (
|
||||
name: value: "proxy_set_header ${value} $auth_${name};"
|
||||
) location.config.oidc.headers}
|
||||
'';
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
config.extraConfig =
|
||||
let
|
||||
mkLocationConfig =
|
||||
name: location:
|
||||
let
|
||||
prefix = prefixFor name;
|
||||
in
|
||||
''
|
||||
location .${prefix}_auth {
|
||||
internal;
|
||||
proxy_pass ${location.oidc.nginxOidcUrl}/auth/${location.oidc.clientName}${
|
||||
lib.optionalString (location.oidc.role != null) "?role=${location.oidc.role}"
|
||||
};
|
||||
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
||||
proxy_set_header X-Callback-Path ${location.oidc.callbackPath};
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Real-Ip $remote_addr;
|
||||
}
|
||||
location = ${location.oidc.callbackPath} {
|
||||
proxy_pass ${location.oidc.nginxOidcUrl}/callback/${location.oidc.clientName};
|
||||
proxy_set_header X-Real-Ip $remote_addr;
|
||||
}
|
||||
'';
|
||||
in
|
||||
lib.pipe vhost.config.locations [
|
||||
(lib.filterAttrs (_: location: location.oidc.enable))
|
||||
(lib.mapAttrsToList mkLocationConfig)
|
||||
lib.mkMerge
|
||||
];
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
56
nix/package.nix
Normal file
56
nix/package.nix
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
callPackage,
|
||||
installShellFiles,
|
||||
lib,
|
||||
stdenv,
|
||||
versionCheckHook,
|
||||
}:
|
||||
|
||||
let
|
||||
cargoNix = callPackage ../Cargo.nix { };
|
||||
|
||||
unwrapped = cargoNix.rootCrate.build.overrideAttrs {
|
||||
src = lib.fileset.toSource {
|
||||
root = ../.;
|
||||
fileset = lib.fileset.unions [
|
||||
../src
|
||||
../config.yml
|
||||
];
|
||||
};
|
||||
};
|
||||
in
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "nginx-oidc";
|
||||
inherit (unwrapped) version;
|
||||
|
||||
src = unwrapped;
|
||||
|
||||
nativeBuildInputs = [ installShellFiles ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
cp -r . $out
|
||||
installShellCompletion --cmd nginx-oidc \
|
||||
--bash <(COMPLETE=bash $out/bin/nginx-oidc) \
|
||||
--fish <(COMPLETE=fish $out/bin/nginx-oidc) \
|
||||
--zsh <(COMPLETE=zsh $out/bin/nginx-oidc)
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
nativeInstallCheckInputs = [ versionCheckHook ];
|
||||
versionCheckProgramArg = "--version";
|
||||
doInstallCheck = true;
|
||||
|
||||
passthru = { inherit unwrapped; };
|
||||
|
||||
meta = {
|
||||
description = "OpenID Connect Integration for nginx via auth_request";
|
||||
homepage = "https://github.com/Defelo/nginx-oidc";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "nginx-oidc";
|
||||
maintainers = with lib.maintainers; [ defelo ];
|
||||
};
|
||||
}
|
202
nix/test.nix
Normal file
202
nix/test.nix
Normal file
|
@ -0,0 +1,202 @@
|
|||
{
|
||||
lib,
|
||||
self,
|
||||
testers,
|
||||
}:
|
||||
|
||||
testers.runNixOSTest {
|
||||
name = "nginx-oidc";
|
||||
meta.maintainers = with lib.maintainers; [ defelo ];
|
||||
|
||||
nodes.machine =
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
let
|
||||
certs = pkgs.runCommand "certs" { } ''
|
||||
mkdir $out
|
||||
cd $out
|
||||
${lib.getExe pkgs.minica} -domains 'localhost,id.localhost,whoami.localhost'
|
||||
'';
|
||||
|
||||
kanidm = pkgs.kanidmWithSecretProvisioning;
|
||||
|
||||
clientSecretFile = builtins.toFile "client-secret" "nTT6Ork2kMFwZcZ98OyHmXwd3PKWSlIc";
|
||||
in
|
||||
|
||||
{
|
||||
imports = [ self.nixosModules.default ];
|
||||
|
||||
services.kanidm = {
|
||||
enableServer = true;
|
||||
enableClient = true;
|
||||
package = kanidm;
|
||||
|
||||
serverSettings = {
|
||||
domain = "id.localhost";
|
||||
origin = "https://id.localhost";
|
||||
|
||||
bindaddress = "127.0.0.1:8001";
|
||||
trust_x_forward_for = true;
|
||||
|
||||
tls_chain = "${certs}/localhost/cert.pem";
|
||||
tls_key = "${certs}/localhost/key.pem";
|
||||
};
|
||||
|
||||
clientSettings = {
|
||||
uri = "https://id.localhost";
|
||||
ca_path = "${certs}/minica.pem";
|
||||
};
|
||||
|
||||
provision = {
|
||||
enable = true;
|
||||
|
||||
persons.user = {
|
||||
displayName = "Test User";
|
||||
mailAddresses = [ "user@example.com" ];
|
||||
};
|
||||
|
||||
groups = {
|
||||
whoami_users.members = [ "user" ];
|
||||
};
|
||||
|
||||
systems.oauth2 = {
|
||||
whoami = {
|
||||
displayName = "whoami";
|
||||
originUrl = "https://whoami.localhost${
|
||||
config.services.nginx.virtualHosts."whoami.localhost".locations."/".oidc.callbackPath
|
||||
}";
|
||||
originLanding = "https://whoami.localhost/";
|
||||
basicSecretFile = clientSecretFile;
|
||||
preferShortUsername = true;
|
||||
scopeMaps.whoami_users = [
|
||||
"openid"
|
||||
"email"
|
||||
];
|
||||
claimMaps.roles.valuesByGroup = {
|
||||
whoami_users = [
|
||||
"whoami"
|
||||
"xyz"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.whoami = {
|
||||
enable = true;
|
||||
port = 8000;
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedProxySettings = true;
|
||||
|
||||
virtualHosts."whoami.localhost" = {
|
||||
sslCertificate = "${certs}/localhost/cert.pem";
|
||||
sslCertificateKey = "${certs}/localhost/key.pem";
|
||||
forceSSL = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:8000";
|
||||
oidc.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
virtualHosts."id.localhost" = {
|
||||
sslCertificate = "${certs}/localhost/cert.pem";
|
||||
sslCertificateKey = "${certs}/localhost/key.pem";
|
||||
forceSSL = true;
|
||||
locations."/".proxyPass = "https://127.0.0.1:8001";
|
||||
};
|
||||
|
||||
oidc = {
|
||||
enable = true;
|
||||
settings = {
|
||||
cookie_secret_path = builtins.toFile "cookie-secret" "super-secure-and-definitely-random-cookie-secret";
|
||||
ca_certs = [ "${certs}/minica.pem" ];
|
||||
clients."whoami.localhost" = {
|
||||
issuer = "https://id.localhost/oauth2/openid/whoami";
|
||||
client_id = "whoami";
|
||||
client_secret_path = clientSecretFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
security.pki.certificateFiles = [ "${certs}/minica.pem" ];
|
||||
|
||||
networking.hosts = lib.genAttrs [ "127.0.0.1" "::1" ] (_: [
|
||||
"whoami.localhost"
|
||||
"id.localhost"
|
||||
]);
|
||||
};
|
||||
|
||||
interactive.nodes.machine = {
|
||||
virtualisation.graphics = false;
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PermitRootLogin = "yes";
|
||||
PermitEmptyPasswords = "yes";
|
||||
};
|
||||
};
|
||||
security.pam.services.sshd.allowNullPassword = true;
|
||||
virtualisation.forwardPorts = [
|
||||
{
|
||||
from = "host";
|
||||
host.port = 2222;
|
||||
guest.port = 22;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
import re
|
||||
|
||||
machine.wait_for_unit("kanidm.service")
|
||||
machine.wait_for_unit("nginx-oidc.service")
|
||||
machine.wait_for_unit("nginx.service")
|
||||
machine.wait_for_unit("whoami.service")
|
||||
|
||||
def search(*args, **kwargs):
|
||||
assert (match := re.search(*args, **kwargs))
|
||||
return match
|
||||
|
||||
result = machine.succeed("kanidmd recover-account idm_admin -o json")
|
||||
idm_admin_password = json.loads(search(r'^\{"password".+$', result, re.M)[0])["password"]
|
||||
machine.succeed(f"KANIDM_PASSWORD=\"{idm_admin_password}\" kanidm login -D idm_admin")
|
||||
|
||||
result = machine.succeed("kanidmd recover-account user -o json")
|
||||
user_password = json.loads(search(r'^\{"password".+$', result, re.M)[0])["password"]
|
||||
|
||||
result = machine.succeed("kanidm person get user")
|
||||
user_id = search(r"^uuid: (.+)$", result, re.M)[1]
|
||||
|
||||
curl = "curl --cookie cookies --cookie-jar cookies"
|
||||
|
||||
resp = machine.succeed(f"{curl} -L https://whoami.localhost")
|
||||
assert "Authenticate to access whoami" in resp
|
||||
|
||||
machine.succeed(f"{curl} https://id.localhost/ui/login/begin -d 'username=user&password=&totp='")
|
||||
resp = machine.succeed(f"{curl} https://id.localhost/ui/login/pw -L -d 'password={user_password}'")
|
||||
assert "Consent to Proceed to whoami" in resp
|
||||
consent_token = search("name=\"consent_token\" value=\"(.+)\"", resp)[1]
|
||||
|
||||
resp = machine.succeed(f"{curl} https://id.localhost/ui/oauth2/consent -L -d 'consent_token={consent_token.replace("=", "%3d")}'")
|
||||
assert f"X-Auth-Sub: {user_id}" in resp
|
||||
assert "X-Auth-Name: Test User" in resp
|
||||
assert "X-Auth-Username: user" in resp
|
||||
assert "X-Auth-Email: user@example.com" in resp
|
||||
assert "X-Auth-Roles: whoami xyz" in resp
|
||||
|
||||
resp = machine.succeed(f"{curl} https://whoami.localhost/")
|
||||
assert f"X-Auth-Sub: {user_id}" in resp
|
||||
assert "X-Auth-Name: Test User" in resp
|
||||
assert "X-Auth-Username: user" in resp
|
||||
assert "X-Auth-Email: user@example.com" in resp
|
||||
assert "X-Auth-Roles: whoami xyz" in resp
|
||||
|
||||
machine.log(machine.succeed("SYSTEMD_COLORS=1 systemd-analyze security nginx-oidc.service --threshold=11 --no-pager"))
|
||||
'';
|
||||
}
|
695
nixos-options.md
Normal file
695
nixos-options.md
Normal file
|
@ -0,0 +1,695 @@
|
|||
## services\.nginx\.oidc\.enable
|
||||
|
||||
Whether to enable nginx-oidc\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
boolean
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` false `
|
||||
|
||||
|
||||
|
||||
*Example:*
|
||||
` true `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.package
|
||||
|
||||
|
||||
|
||||
The nginx-oidc package to use\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
package
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` nginx-oidc.packages.${system}.nginx-oidc `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.extraConfigFiles
|
||||
|
||||
|
||||
|
||||
Extra configuration files to include\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
list of absolute path
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` [ ] `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress
|
||||
|
||||
|
||||
|
||||
Where the server should listen for incoming connections\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
attribute-tagged union
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
|
||||
```
|
||||
{
|
||||
unix = {
|
||||
path = "/run/nginx-oidc/http.socket";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress\.tcp
|
||||
|
||||
|
||||
|
||||
Listen on a TCP socket\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
submodule
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress\.tcp\.host
|
||||
|
||||
|
||||
|
||||
Host on which the server should listen\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress\.tcp\.port
|
||||
|
||||
|
||||
|
||||
Port on which the server should listen\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress\.unix
|
||||
|
||||
|
||||
|
||||
Listen on a unix socket\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
submodule
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.listenAddress\.unix\.path
|
||||
|
||||
|
||||
|
||||
Path of the unix socket on which the server should listen\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
absolute path
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.logLevel
|
||||
|
||||
|
||||
|
||||
Log level of the nginx-oidc server\. See [https://docs\.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct\.EnvFilter\.html\#directives](https://docs\.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct\.EnvFilter\.html\#directives) for more information\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "info" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings
|
||||
|
||||
|
||||
|
||||
Configuration of the nginx-oidc server\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
YAML value
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.ca_certs
|
||||
|
||||
|
||||
|
||||
List of paths of additional CA certificates to trust\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
list of absolute path
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` [ ] `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients
|
||||
|
||||
|
||||
|
||||
OIDC clients
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
attribute set of (YAML value)
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` { } `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.auth_cookie_ttl_secs
|
||||
|
||||
|
||||
|
||||
Number of seconds the auth cookie is valid\. This cookie is used to remember the original URL and authentication state when the user is redirected to the OIDC provider\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
unsigned integer, meaning >=0
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` 600 `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.client_id
|
||||
|
||||
|
||||
|
||||
Client ID of the OIDC client\. Defaults to the attribute name\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.client_secret_path
|
||||
|
||||
|
||||
|
||||
Path of the file containing the client secret of the OIDC client\. Set to ` null ` for public clients\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or absolute path
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` null `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.issuer
|
||||
|
||||
|
||||
|
||||
Issuer URL of the OIDC client (without the ` /.well-known/openid-configuration ` suffix)
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.keep_access_token
|
||||
|
||||
|
||||
|
||||
Whether to remember the OIDC access token after a successful authorization\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
boolean
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` true `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.keep_refresh_token
|
||||
|
||||
|
||||
|
||||
Whether to remember the OIDC refresh token after a successful authorization\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
boolean
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` true `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.real_ip_header
|
||||
|
||||
|
||||
|
||||
Header which contains the user’s real ip\. If unset, the session is not bound to the user’s ip address\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Real-Ip" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.roles_claim
|
||||
|
||||
|
||||
|
||||
OIDC claim which contains a list of the user’s roles\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "roles" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.scopes
|
||||
|
||||
|
||||
|
||||
Scopes to request from the OIDC provider\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
list of string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
|
||||
```
|
||||
[
|
||||
"openid"
|
||||
"email"
|
||||
]
|
||||
```
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.clients\.\<name>\.session_cookie_ttl_secs
|
||||
|
||||
|
||||
|
||||
Number of seconds the session cookie is valid\. After the session cookie has expired nginx-oidc first tries to refetch the user’s information by using the access and refresh tokens\. The user is only redirected to the OIDC provider if these attempts do not succeed\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
unsigned integer, meaning >=0
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` 60 `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.oidc\.settings\.cookie_secret_path
|
||||
|
||||
|
||||
|
||||
Path of the file containing the secret used to sign and encrypt session cookies\. If unset, a random secret is generated on each run\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or absolute path
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` null `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.enable
|
||||
|
||||
|
||||
|
||||
Whether to enable OIDC auth for this location\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
boolean
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` false `
|
||||
|
||||
|
||||
|
||||
*Example:*
|
||||
` true `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.callbackPath
|
||||
|
||||
|
||||
|
||||
Path for the OAuth2 redirect URL\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string matching the pattern ^/\.\*$
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "/${prefix}/callback" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.clientName
|
||||
|
||||
|
||||
|
||||
Name of the client configured in nginx-oidc\. Defaults to the ` virtualHosts ` attribute name\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.headers\.email
|
||||
|
||||
|
||||
|
||||
Header to set via ` proxy_set_header ` containing the ` email ` claim\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Auth-Email" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.headers\.name
|
||||
|
||||
|
||||
|
||||
Header to set via ` proxy_set_header ` containing the ` name ` claim (display name of the user)\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Auth-Name" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.headers\.roles
|
||||
|
||||
|
||||
|
||||
Header to set via ` proxy_set_header ` containing the roles of the user\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Auth-Roles" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.headers\.sub
|
||||
|
||||
|
||||
|
||||
Header to set via ` proxy_set_header ` containing the ` subject ` claim (unique user identifier)\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Auth-Sub" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.headers\.username
|
||||
|
||||
|
||||
|
||||
Header to set via ` proxy_set_header ` containing the ` preferred_username ` claim\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` "X-Auth-Username" `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.nginxOidcUrl
|
||||
|
||||
|
||||
|
||||
Base URL of the nginx-oidc server which nginx should use\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
|
||||
```
|
||||
{
|
||||
tcp = "http://${listenAddressTcp}";
|
||||
unix = "http://unix:${listenAddressUnix}:";
|
||||
}.${listenAddressType}
|
||||
```
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
||||
|
||||
## services\.nginx\.virtualHosts\.\<name>\.locations\.\<name>\.oidc\.role
|
||||
|
||||
|
||||
|
||||
Name of a role a user has to have in order to be granted access\.
|
||||
|
||||
|
||||
|
||||
*Type:*
|
||||
null or string
|
||||
|
||||
|
||||
|
||||
*Default:*
|
||||
` null `
|
||||
|
||||
*Declared by:*
|
||||
- [nix/module\.nix](https://github.com/Defelo/nginx-oidc/blob/develop/nix/module\.nix)
|
||||
|
||||
|
30
src/cli/mod.rs
Normal file
30
src/cli/mod.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::CompleteEnv;
|
||||
use serve::ServeCommand;
|
||||
|
||||
mod serve;
|
||||
|
||||
pub async fn main() -> anyhow::Result<()> {
|
||||
CompleteEnv::with_factory(Cli::command).complete();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
match cli.command {
|
||||
Command::Serve(cmd) => cmd.invoke().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Start the HTTP API server
|
||||
Serve(ServeCommand),
|
||||
}
|
75
src/cli/serve.rs
Normal file
75
src/cli/serve.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use std::{collections::HashMap, net::SocketAddr, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use clap::Args;
|
||||
use futures::{TryStreamExt, stream::FuturesUnordered};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::{
|
||||
config, http,
|
||||
oidc::{client::ClientWithConfig, make_http_client},
|
||||
};
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ServeCommand {
|
||||
#[command(flatten)]
|
||||
listen_address: ListenAddress,
|
||||
|
||||
/// Path of the config file
|
||||
#[arg(long)]
|
||||
config: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[group(required = true, multiple = false)]
|
||||
struct ListenAddress {
|
||||
/// Listen on a TCP socket
|
||||
#[arg(long)]
|
||||
tcp: Option<SocketAddr>,
|
||||
|
||||
/// Listen on a unix socket
|
||||
#[arg(long)]
|
||||
unix: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ServeCommand {
|
||||
pub async fn invoke(self) -> anyhow::Result<()> {
|
||||
info!("Loading config");
|
||||
let config = config::load(self.config.into_iter().map(Into::into))?;
|
||||
debug!("Config loaded: {config:?}");
|
||||
|
||||
let http = make_http_client(config.ca_certs).context("Failed to build http client")?;
|
||||
|
||||
info!("Loading clients");
|
||||
let clients = config
|
||||
.clients
|
||||
.into_iter()
|
||||
.map(|(key, value)| async {
|
||||
anyhow::Ok((key, ClientWithConfig::from_config(value, &http).await?))
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.try_collect::<HashMap<_, _>>()
|
||||
.await?;
|
||||
debug!("Clients loaded: {clients:?}");
|
||||
|
||||
http::serve(
|
||||
self.listen_address.try_into()?,
|
||||
config.cookie_secret.as_deref(),
|
||||
clients,
|
||||
http,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ListenAddress> for http::ListenAddress {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ListenAddress) -> Result<Self, Self::Error> {
|
||||
value
|
||||
.tcp
|
||||
.map(Self::Tcp)
|
||||
.or(value.unix.map(Self::Unix))
|
||||
.ok_or_else(|| anyhow!("no listen address selected"))
|
||||
}
|
||||
}
|
229
src/config.rs
Normal file
229
src/config.rs
Normal file
|
@ -0,0 +1,229 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, ensure};
|
||||
use config::{File, FileFormat};
|
||||
use openidconnect::{ClientId, ClientSecret, IssuerUrl, Scope};
|
||||
|
||||
pub fn load<'a>(config_path: impl IntoIterator<Item = Cow<'a, Path>>) -> anyhow::Result<Config> {
|
||||
load_with_defaults([], config_path)
|
||||
}
|
||||
|
||||
fn load_with_defaults<'a>(
|
||||
defaults: impl IntoIterator<Item = Cow<'a, str>>,
|
||||
config_path: impl IntoIterator<Item = Cow<'a, Path>>,
|
||||
) -> anyhow::Result<Config> {
|
||||
defaults
|
||||
.into_iter()
|
||||
.chain([include_str!("../config.yml").into()])
|
||||
.map(Ok)
|
||||
.chain(
|
||||
config_path
|
||||
.into_iter()
|
||||
.map(|path| std::fs::read_to_string(path).map(Into::into)),
|
||||
)
|
||||
.map(|content| content.map(|content| File::from_str(&content, FileFormat::Yaml)))
|
||||
.try_fold(config::Config::builder(), |builder, source| {
|
||||
anyhow::Ok(builder.add_source(source?))
|
||||
})?
|
||||
.build()
|
||||
.context("Failed to read config file")?
|
||||
.try_deserialize::<raw::Config>()
|
||||
.context("Failed to deserialize config")?
|
||||
.try_into()
|
||||
.context("Failed to load config")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub cookie_secret: Option<Vec<u8>>,
|
||||
pub ca_certs: Vec<PathBuf>,
|
||||
pub clients: HashMap<String, ClientConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
pub issuer: IssuerUrl,
|
||||
pub client_id: ClientId,
|
||||
pub client_secret: Option<ClientSecret>,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub roles_claim: Option<String>,
|
||||
pub auth_cookie_ttl: Duration,
|
||||
pub session_cookie_ttl: Duration,
|
||||
pub keep_access_token: bool,
|
||||
pub keep_refresh_token: bool,
|
||||
pub real_ip_header: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<raw::Config> for Config {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(value: raw::Config) -> anyhow::Result<Self> {
|
||||
let cookie_secret = value
|
||||
.cookie_secret_path
|
||||
.map(|path| {
|
||||
std::fs::read(&path)
|
||||
.with_context(|| format!("Failed to read file '{}'", path.display()))
|
||||
})
|
||||
.transpose()?;
|
||||
ensure!(
|
||||
cookie_secret.as_ref().is_none_or(|c| c.len() >= 32),
|
||||
"cookie secret is too short (min. 32 bytes)"
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
cookie_secret,
|
||||
ca_certs: value.ca_certs,
|
||||
clients: value
|
||||
.clients
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
anyhow::Ok((
|
||||
key.clone(),
|
||||
ClientConfig::try_from_raw(key.clone(), value)
|
||||
.with_context(|| format!("Failed to load client '{key}'"))?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, _>>()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
fn try_from_raw(key: String, value: raw::ClientConfig) -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
issuer: value.issuer,
|
||||
client_id: value.client_id.unwrap_or_else(|| ClientId::new(key)),
|
||||
client_secret: value
|
||||
.client_secret_path
|
||||
.map(|path| {
|
||||
std::fs::read_to_string(&path)
|
||||
.map(ClientSecret::new)
|
||||
.with_context(|| format!("Failed to read file '{}'", path.display()))
|
||||
})
|
||||
.transpose()?,
|
||||
scopes: value.scopes,
|
||||
roles_claim: value.roles_claim,
|
||||
auth_cookie_ttl: Duration::from_secs(value.auth_cookie_ttl_secs),
|
||||
session_cookie_ttl: Duration::from_secs(value.session_cookie_ttl_secs),
|
||||
keep_access_token: value.keep_access_token,
|
||||
keep_refresh_token: value.keep_refresh_token,
|
||||
real_ip_header: value.real_ip_header,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
mod raw {
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use openidconnect::{ClientId, IssuerUrl, Scope};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub cookie_secret_path: Option<PathBuf>,
|
||||
pub ca_certs: Vec<PathBuf>,
|
||||
pub clients: HashMap<String, ClientConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientConfig {
|
||||
pub issuer: IssuerUrl,
|
||||
pub client_id: Option<ClientId>,
|
||||
pub client_secret_path: Option<PathBuf>,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub roles_claim: Option<String>,
|
||||
pub auth_cookie_ttl_secs: u64,
|
||||
pub session_cookie_ttl_secs: u64,
|
||||
pub keep_access_token: bool,
|
||||
pub keep_refresh_token: bool,
|
||||
pub real_ip_header: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load() {
|
||||
let secrets = setup_secrets();
|
||||
let expected = Config {
|
||||
cookie_secret: Some(b"super-secure-and-definitely-random-cookie-secret".into()),
|
||||
ca_certs: vec![],
|
||||
clients: [(
|
||||
"test".into(),
|
||||
ClientConfig {
|
||||
issuer: IssuerUrl::new(
|
||||
"https://id.example.com/oauth2/openid/test-client".into(),
|
||||
)
|
||||
.unwrap(),
|
||||
client_id: ClientId::new("test-client-id".into()),
|
||||
client_secret: Some(ClientSecret::new("test-client-secret-123".into())),
|
||||
scopes: vec![Scope::new("test".into())],
|
||||
roles_claim: Some("test-roles".into()),
|
||||
auth_cookie_ttl: Duration::from_secs(42),
|
||||
session_cookie_ttl: Duration::from_secs(1337),
|
||||
keep_access_token: false,
|
||||
keep_refresh_token: false,
|
||||
real_ip_header: Some("X-Real-Ip".into()),
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
};
|
||||
|
||||
let cookie_secret_path = secrets.cookie.path().display();
|
||||
let test_client_secret_path = secrets.test_client.path().display();
|
||||
let defaults = format!(
|
||||
r#"
|
||||
cookie_secret_path: "{cookie_secret_path}"
|
||||
clients:
|
||||
test:
|
||||
issuer: "https://id.example.com/oauth2/openid/test-client"
|
||||
client_id: "test-client-id"
|
||||
client_secret_path: "{test_client_secret_path}"
|
||||
scopes: [ "test" ]
|
||||
roles_claim: "test-roles"
|
||||
auth_cookie_ttl_secs: 42
|
||||
session_cookie_ttl_secs: 1337
|
||||
keep_access_token: false
|
||||
keep_refresh_token: false
|
||||
real_ip_header: "X-Real-Ip"
|
||||
"#
|
||||
);
|
||||
|
||||
let config = load_with_defaults([&defaults].map(Into::into), []).unwrap();
|
||||
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
struct Secrets {
|
||||
cookie: NamedTempFile,
|
||||
test_client: NamedTempFile,
|
||||
}
|
||||
|
||||
fn setup_secrets() -> Secrets {
|
||||
let mut secrets = Secrets {
|
||||
cookie: NamedTempFile::new().unwrap(),
|
||||
test_client: NamedTempFile::new().unwrap(),
|
||||
};
|
||||
write!(
|
||||
&mut secrets.cookie,
|
||||
"super-secure-and-definitely-random-cookie-secret"
|
||||
)
|
||||
.unwrap();
|
||||
write!(&mut secrets.test_client, "test-client-secret-123").unwrap();
|
||||
secrets
|
||||
}
|
||||
}
|
107
src/http/cookie.rs
Normal file
107
src/http/cookie.rs
Normal file
|
@ -0,0 +1,107 @@
|
|||
use std::{borrow::Cow, net::IpAddr, time::SystemTime};
|
||||
|
||||
use axum_extra::extract::{
|
||||
PrivateCookieJar,
|
||||
cookie::{Cookie, SameSite},
|
||||
};
|
||||
use openidconnect::{AccessToken, RefreshToken, url::Url};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::oidc::{auth::AuthState, userinfo::UserInfo};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CookieWrapper<'a> {
|
||||
client_id: Cow<'a, str>,
|
||||
ip: Option<IpAddr>,
|
||||
data: CookieData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum CookieData {
|
||||
Auth(AuthCookie),
|
||||
Session(SessionCookie),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthCookie {
|
||||
pub issued_at: SystemTime,
|
||||
pub original_url: Url,
|
||||
pub auth_state: AuthState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionCookie {
|
||||
pub issued_at: SystemTime,
|
||||
pub userinfo: UserInfo,
|
||||
pub access_token: Option<AccessToken>,
|
||||
pub refresh_token: Option<RefreshToken>,
|
||||
}
|
||||
|
||||
fn cookie_name(client_id: &str) -> String {
|
||||
format!("_nginx_oidc_{client_id}")
|
||||
}
|
||||
|
||||
pub fn get_cookie<C: TryFrom<CookieData>>(
|
||||
jar: &PrivateCookieJar,
|
||||
client_id: &str,
|
||||
ip: Option<IpAddr>,
|
||||
) -> Option<C> {
|
||||
serde_json::from_str::<CookieWrapper>(jar.get(&cookie_name(client_id))?.value())
|
||||
.ok()
|
||||
.and_then(|c| (c.client_id == client_id && c.ip == ip).then_some(c.data))?
|
||||
.try_into()
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn make_cookie(
|
||||
client_id: &str,
|
||||
ip: Option<IpAddr>,
|
||||
value: impl Into<CookieData>,
|
||||
secure: bool,
|
||||
) -> anyhow::Result<Cookie<'static>> {
|
||||
let mut cookie = Cookie::new(
|
||||
cookie_name(client_id),
|
||||
serde_json::to_string(&CookieWrapper {
|
||||
client_id: client_id.into(),
|
||||
ip,
|
||||
data: value.into(),
|
||||
})?,
|
||||
);
|
||||
cookie.set_path("/");
|
||||
cookie.set_http_only(true);
|
||||
cookie.set_secure(secure);
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
Ok(cookie)
|
||||
}
|
||||
|
||||
impl TryFrom<CookieData> for AuthCookie {
|
||||
type Error = ();
|
||||
fn try_from(value: CookieData) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
CookieData::Auth(auth_cookie) => Ok(auth_cookie),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<CookieData> for SessionCookie {
|
||||
type Error = ();
|
||||
fn try_from(value: CookieData) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
CookieData::Session(session_cookie) => Ok(session_cookie),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthCookie> for CookieData {
|
||||
fn from(value: AuthCookie) -> Self {
|
||||
Self::Auth(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionCookie> for CookieData {
|
||||
fn from(value: SessionCookie) -> Self {
|
||||
Self::Session(value)
|
||||
}
|
||||
}
|
49
src/http/mod.rs
Normal file
49
src/http/mod.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use openidconnect::reqwest;
|
||||
use state::State;
|
||||
use tokio::net::{TcpListener, UnixListener};
|
||||
use tracing::info;
|
||||
|
||||
use crate::oidc::client::ClientMap;
|
||||
|
||||
mod cookie;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
pub async fn serve(
|
||||
listen_address: ListenAddress,
|
||||
cookie_secret: Option<&[u8]>,
|
||||
clients: ClientMap,
|
||||
http: reqwest::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let router = routes::router().with_state(State::new(cookie_secret, clients, http));
|
||||
|
||||
match listen_address {
|
||||
ListenAddress::Tcp(addr) => {
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("Failed to bind to {addr} (tcp)"))?;
|
||||
info!("Listening on {} (tcp)", listener.local_addr()?);
|
||||
axum::serve(listener, router).await?;
|
||||
}
|
||||
ListenAddress::Unix(path) => {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path)
|
||||
.with_context(|| format!("Failed to bind to {} (unix)", path.display()))?;
|
||||
info!("Listening on {} (unix)", path.display());
|
||||
axum::serve(listener, router).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ListenAddress {
|
||||
Tcp(SocketAddr),
|
||||
Unix(PathBuf),
|
||||
}
|
237
src/http/routes/auth.rs
Normal file
237
src/http/routes/auth.rs
Normal file
|
@ -0,0 +1,237 @@
|
|||
use std::{net::IpAddr, time::SystemTime};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::PrivateCookieJar;
|
||||
use openidconnect::{reqwest, url::Url};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, error};
|
||||
|
||||
use super::{get_header, get_ip};
|
||||
use crate::{
|
||||
http::{
|
||||
cookie::{AuthCookie, SessionCookie, get_cookie, make_cookie},
|
||||
state::State,
|
||||
},
|
||||
oidc::{
|
||||
auth::make_auth_url,
|
||||
client::ClientWithConfig,
|
||||
token::refresh,
|
||||
userinfo::{UserInfo, get_userinfo},
|
||||
},
|
||||
utils::UrlExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthQuery {
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn route(
|
||||
state: State,
|
||||
Path(client_id): Path<String>,
|
||||
Query(query): Query<AuthQuery>,
|
||||
cookies: PrivateCookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let client = state.clients.get(&client_id).ok_or_else(|| {
|
||||
error!("client '{client_id}' not found");
|
||||
StatusCode::NOT_FOUND
|
||||
})?;
|
||||
|
||||
let ip = get_ip(&headers, &client.config)
|
||||
.inspect_err(|err| error!("failed to get user's real ip: {err:#}"))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let original_url = get_header(&headers, "x-original-url")
|
||||
.and_then(|x| Url::parse(x).context("header is not a valid url"))
|
||||
.inspect_err(|err| error!("failed to read x-original-url header: {err:#}"))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let ctx = Context {
|
||||
client,
|
||||
http: &state.http,
|
||||
client_id,
|
||||
role: query.role,
|
||||
headers,
|
||||
original_url,
|
||||
ip,
|
||||
};
|
||||
|
||||
if let Some(cookie) = get_cookie::<SessionCookie>(&cookies, &ctx.client_id, ip) {
|
||||
if SystemTime::now()
|
||||
.duration_since(cookie.issued_at)
|
||||
.is_ok_and(|d| d < client.config.session_cookie_ttl)
|
||||
{
|
||||
handle_valid_session(ctx, cookie)
|
||||
} else {
|
||||
handle_expired_session(ctx, cookies, cookie).await
|
||||
}
|
||||
} else {
|
||||
redirect_to_oidc_provider(ctx, cookies)
|
||||
}
|
||||
}
|
||||
|
||||
struct Context<'a> {
|
||||
client: &'a ClientWithConfig,
|
||||
http: &'a reqwest::Client,
|
||||
client_id: String,
|
||||
role: Option<String>,
|
||||
headers: HeaderMap,
|
||||
original_url: Url,
|
||||
ip: Option<IpAddr>,
|
||||
}
|
||||
|
||||
fn handle_valid_session(ctx: Context, cookie: SessionCookie) -> Result<Response, StatusCode> {
|
||||
let headers = make_auth_headers(&cookie.userinfo);
|
||||
|
||||
let status = if ctx
|
||||
.role
|
||||
.is_none_or(|role| cookie.userinfo.roles.contains(&role))
|
||||
{
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
};
|
||||
|
||||
Ok((status, headers).into_response())
|
||||
}
|
||||
|
||||
fn make_auth_headers(userinfo: &UserInfo) -> HeaderMap {
|
||||
let mut headers = [
|
||||
("x-auth-sub", Some(userinfo.sub.as_str())),
|
||||
("x-auth-name", userinfo.name.as_ref().map(|s| s.as_str())),
|
||||
(
|
||||
"x-auth-username",
|
||||
userinfo.username.as_ref().map(|s| s.as_str()),
|
||||
),
|
||||
("x-auth-email", userinfo.email.as_ref().map(|s| s.as_str())),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, value)| Some((name.try_into().ok()?, value?.try_into().ok()?)))
|
||||
.collect::<HeaderMap>();
|
||||
|
||||
if !userinfo.roles.is_empty() {
|
||||
if let Ok(roles) = userinfo.roles.join(" ").try_into() {
|
||||
headers.insert("x-auth-roles", roles);
|
||||
}
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
async fn handle_expired_session(
|
||||
ctx: Context<'_>,
|
||||
cookies: PrivateCookieJar,
|
||||
mut cookie: SessionCookie,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let Some(access_token) = cookie.access_token.clone() else {
|
||||
debug!("reauthenticating due to missing access token");
|
||||
return redirect_to_oidc_provider(ctx, cookies);
|
||||
};
|
||||
|
||||
debug!("trying to use cached access token");
|
||||
let userinfo = match get_userinfo(
|
||||
access_token,
|
||||
ctx.client,
|
||||
ctx.http,
|
||||
cookie.userinfo.sub.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(userinfo) => userinfo,
|
||||
Err(err) => {
|
||||
debug!("failed to get userinfo: {err:#}");
|
||||
|
||||
let Some(refresh_token) = cookie.refresh_token.as_ref() else {
|
||||
debug!("reauthenticating due to missing refresh token");
|
||||
return redirect_to_oidc_provider(ctx, cookies);
|
||||
};
|
||||
|
||||
let Ok(tokens) = refresh(refresh_token, ctx.client, ctx.http).await else {
|
||||
debug!("reauthenticating due to refresh failure");
|
||||
return redirect_to_oidc_provider(ctx, cookies);
|
||||
};
|
||||
|
||||
cookie.access_token = Some(tokens.access_token.clone());
|
||||
cookie.refresh_token = tokens.refresh_token;
|
||||
get_userinfo(
|
||||
tokens.access_token,
|
||||
ctx.client,
|
||||
ctx.http,
|
||||
cookie.userinfo.sub,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|err| error!("failed to get userinfo: {err:#}"))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
}
|
||||
};
|
||||
|
||||
let cookie = SessionCookie {
|
||||
userinfo,
|
||||
issued_at: SystemTime::now(),
|
||||
..cookie
|
||||
};
|
||||
let cookies = cookies.add(
|
||||
make_cookie(
|
||||
&ctx.client_id,
|
||||
ctx.ip,
|
||||
cookie.clone(),
|
||||
ctx.original_url.is_secure(),
|
||||
)
|
||||
.inspect_err(|err| {
|
||||
error!("failed to serialize auth cookie: {err:#}");
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
);
|
||||
let response = handle_valid_session(ctx, cookie)?;
|
||||
|
||||
Ok((cookies, response).into_response())
|
||||
}
|
||||
|
||||
fn redirect_to_oidc_provider(
|
||||
ctx: Context,
|
||||
cookies: PrivateCookieJar,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let callback_path = get_header(&ctx.headers, "x-callback-path")
|
||||
.inspect_err(|err| error!("failed to read x-callback-path header: {err:#}"))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let callback_url = ctx
|
||||
.original_url
|
||||
.join(callback_path)
|
||||
.inspect_err(|err| error!("failed to construct callback url: {err:#}"))
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let (auth_url, auth_state) = make_auth_url(ctx.client, callback_url);
|
||||
|
||||
let is_secure = ctx.original_url.is_secure();
|
||||
let cookie = AuthCookie {
|
||||
original_url: ctx.original_url,
|
||||
issued_at: SystemTime::now(),
|
||||
auth_state,
|
||||
};
|
||||
let mut cookie = make_cookie(&ctx.client_id, ctx.ip, cookie, is_secure)
|
||||
.inspect_err(|err| {
|
||||
error!("failed to serialize auth cookie: {err:#}");
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
cookie.set_max_age(Some(
|
||||
ctx.client
|
||||
.config
|
||||
.auth_cookie_ttl
|
||||
.try_into()
|
||||
.inspect_err(|err| error!("Failed to convert auth_cookie_ttl: {err:#}"))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||
));
|
||||
|
||||
Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
[("x-auth-redirect", auth_url.as_str())],
|
||||
cookies.add(cookie),
|
||||
)
|
||||
.into_response())
|
||||
}
|
100
src/http/routes/callback.rs
Normal file
100
src/http/routes/callback.rs
Normal file
|
@ -0,0 +1,100 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::PrivateCookieJar;
|
||||
use openidconnect::{AuthorizationCode, CsrfToken};
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
use super::get_ip;
|
||||
use crate::{
|
||||
http::{
|
||||
cookie::{AuthCookie, SessionCookie, get_cookie, make_cookie},
|
||||
state::State,
|
||||
},
|
||||
oidc::{token::exchange_code, userinfo::get_userinfo},
|
||||
utils::UrlExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
code: AuthorizationCode,
|
||||
state: CsrfToken,
|
||||
}
|
||||
|
||||
pub async fn route(
|
||||
state: State,
|
||||
Path(client_id): Path<String>,
|
||||
Query(query): Query<CallbackQuery>,
|
||||
cookies: PrivateCookieJar,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let Some(client) = state.clients.get(&client_id) else {
|
||||
error!("client '{client_id}' not found");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
let Ok(ip) = get_ip(&headers, &client.config)
|
||||
.inspect_err(|err| error!("failed to get user's real ip: {err:#}"))
|
||||
else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
let Some(cookie) = get_cookie::<AuthCookie>(&cookies, &client_id, ip).filter(|c| {
|
||||
SystemTime::now()
|
||||
.duration_since(c.issued_at)
|
||||
.is_ok_and(|d| d < client.config.auth_cookie_ttl)
|
||||
}) else {
|
||||
return (StatusCode::BAD_REQUEST, "oidc cookie is missing or invalid").into_response();
|
||||
};
|
||||
|
||||
let original_url = cookie.original_url;
|
||||
|
||||
if query.state != cookie.auth_state.csrf_token {
|
||||
return (StatusCode::BAD_REQUEST, "csrf token mismatch").into_response();
|
||||
}
|
||||
|
||||
let tokens = match exchange_code(client, cookie.auth_state, &state.http, query.code).await {
|
||||
Ok(Some(token_response)) => token_response,
|
||||
Ok(None) => {
|
||||
return (StatusCode::UNAUTHORIZED, "invalid authorization code").into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
error!("failed to exchange authorization code: {err:#}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(userinfo) = get_userinfo(tokens.access_token.clone(), client, &state.http, tokens.sub)
|
||||
.await
|
||||
.inspect_err(|err| error!("failed to get userinfo: {err:#}"))
|
||||
else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
let cookie = SessionCookie {
|
||||
issued_at: SystemTime::now(),
|
||||
userinfo,
|
||||
access_token: Some(tokens.access_token).filter(|_| client.config.keep_access_token),
|
||||
refresh_token: tokens
|
||||
.refresh_token
|
||||
.filter(|_| client.config.keep_refresh_token),
|
||||
};
|
||||
let Ok(cookie) =
|
||||
make_cookie(&client_id, ip, cookie, original_url.is_secure()).inspect_err(|err| {
|
||||
error!("failed to serialize session cookie: {err:#}");
|
||||
})
|
||||
else {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
};
|
||||
|
||||
(
|
||||
cookies.add(cookie),
|
||||
Redirect::temporary(original_url.as_str()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
36
src/http/routes/mod.rs
Normal file
36
src/http/routes/mod.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use axum::{Router, http::HeaderMap, routing::get};
|
||||
|
||||
use super::state::State;
|
||||
use crate::config::ClientConfig;
|
||||
|
||||
mod auth;
|
||||
mod callback;
|
||||
|
||||
pub fn router() -> Router<State> {
|
||||
Router::new()
|
||||
.route("/auth/{client_id}", get(auth::route))
|
||||
.route("/callback/{client_id}", get(callback::route))
|
||||
}
|
||||
|
||||
fn get_ip(headers: &HeaderMap, client_config: &ClientConfig) -> anyhow::Result<Option<IpAddr>> {
|
||||
let Some(header) = client_config.real_ip_header.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
get_header(headers, header)?
|
||||
.parse()
|
||||
.with_context(|| format!("failed to read '{header}' header"))
|
||||
.map(Some)
|
||||
.with_context(|| format!("failed to parse ip address from '{header}' header"))
|
||||
}
|
||||
|
||||
fn get_header<'a>(headers: &'a HeaderMap, name: &str) -> anyhow::Result<&'a str> {
|
||||
headers
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow!("header not found"))?
|
||||
.to_str()
|
||||
.context("header contains invalid characters")
|
||||
}
|
47
src/http/state.rs
Normal file
47
src/http/state.rs
Normal file
|
@ -0,0 +1,47 @@
|
|||
use std::{collections::HashMap, convert::Infallible, sync::Arc};
|
||||
|
||||
use axum::extract::{FromRef, FromRequestParts};
|
||||
use axum_extra::extract::cookie::Key;
|
||||
use openidconnect::reqwest;
|
||||
|
||||
use crate::oidc::client::ClientWithConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
cookie_key: Key,
|
||||
pub clients: Arc<HashMap<String, ClientWithConfig>>,
|
||||
pub http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(
|
||||
cookie_secret: Option<&[u8]>,
|
||||
clients: HashMap<String, ClientWithConfig>,
|
||||
http: reqwest::Client,
|
||||
) -> Self {
|
||||
Self {
|
||||
cookie_key: cookie_secret
|
||||
.map(Key::derive_from)
|
||||
.unwrap_or_else(Key::generate),
|
||||
clients: clients.into(),
|
||||
http,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRequestParts<State> for State {
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
_parts: &mut axum::http::request::Parts,
|
||||
state: &State,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Ok(state.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<State> for Key {
|
||||
fn from_ref(input: &State) -> Self {
|
||||
input.cookie_key.clone()
|
||||
}
|
||||
}
|
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod cli;
|
||||
mod config;
|
||||
mod http;
|
||||
mod oidc;
|
||||
mod utils;
|
4
src/main.rs
Normal file
4
src/main.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
nginx_oidc::cli::main().await
|
||||
}
|
43
src/oidc/auth.rs
Normal file
43
src/oidc/auth.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use openidconnect::{
|
||||
CsrfToken, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl,
|
||||
core::CoreAuthenticationFlow, url::Url,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::client::ClientWithConfig;
|
||||
|
||||
pub fn make_auth_url(client: &ClientWithConfig, callback_url: Url) -> (Url, AuthState) {
|
||||
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||
let callback_url = RedirectUrl::from_url(callback_url);
|
||||
|
||||
let (auth_url, csrf_token, nonce) = client
|
||||
.client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scopes(client.config.scopes.iter().cloned())
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.set_redirect_uri(Cow::Borrowed(&callback_url))
|
||||
.url();
|
||||
|
||||
let auth_state = AuthState {
|
||||
callback_url,
|
||||
pkce_verifier,
|
||||
csrf_token,
|
||||
nonce,
|
||||
};
|
||||
|
||||
(auth_url, auth_state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthState {
|
||||
pub callback_url: RedirectUrl,
|
||||
pub pkce_verifier: PkceCodeVerifier,
|
||||
pub csrf_token: CsrfToken,
|
||||
pub nonce: Nonce,
|
||||
}
|
39
src/oidc/client.rs
Normal file
39
src/oidc/client.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use openidconnect::{
|
||||
EndpointMaybeSet, EndpointNotSet, EndpointSet,
|
||||
core::{CoreClient, CoreProviderMetadata},
|
||||
reqwest,
|
||||
};
|
||||
|
||||
use crate::config::ClientConfig;
|
||||
|
||||
pub type ClientMap = HashMap<String, ClientWithConfig>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClientWithConfig {
|
||||
pub client: OidcClient,
|
||||
pub config: ClientConfig,
|
||||
}
|
||||
|
||||
impl ClientWithConfig {
|
||||
pub async fn from_config(config: ClientConfig, http: &reqwest::Client) -> anyhow::Result<Self> {
|
||||
let provider_metadata =
|
||||
CoreProviderMetadata::discover_async(config.issuer.clone(), http).await?;
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
config.client_id.clone(),
|
||||
config.client_secret.clone(),
|
||||
);
|
||||
Ok(Self { client, config })
|
||||
}
|
||||
}
|
||||
|
||||
pub type OidcClient = CoreClient<
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
16
src/oidc/custom_claims.rs
Normal file
16
src/oidc/custom_claims.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
use std::{collections::HashMap, ops::Deref};
|
||||
|
||||
use openidconnect::AdditionalClaims;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CustomClaims(HashMap<String, serde_json::Value>);
|
||||
|
||||
impl AdditionalClaims for CustomClaims {}
|
||||
|
||||
impl Deref for CustomClaims {
|
||||
type Target = HashMap<String, serde_json::Value>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
37
src/oidc/mod.rs
Normal file
37
src/oidc/mod.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use openidconnect::reqwest::{self, Certificate};
|
||||
use tracing::warn;
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
mod custom_claims;
|
||||
pub mod token;
|
||||
pub mod userinfo;
|
||||
|
||||
pub fn make_http_client(
|
||||
ca_certs: impl IntoIterator<Item = impl AsRef<Path>>,
|
||||
) -> anyhow::Result<reqwest::Client> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.use_rustls_tls();
|
||||
|
||||
for cert_path in ca_certs {
|
||||
let cert_path = cert_path.as_ref();
|
||||
warn!("Trusting certificates in {}", cert_path.display());
|
||||
let content = std::fs::read(cert_path)
|
||||
.with_context(|| format!("Failed to read file at {}", cert_path.display()))?;
|
||||
let certs = Certificate::from_pem_bundle(&content).with_context(|| {
|
||||
format!(
|
||||
"Failed to read CA certificates from {}",
|
||||
cert_path.display()
|
||||
)
|
||||
})?;
|
||||
for cert in certs {
|
||||
builder = builder.add_root_certificate(cert);
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().map_err(Into::into)
|
||||
}
|
101
src/oidc/token.rs
Normal file
101
src/oidc/token.rs
Normal file
|
@ -0,0 +1,101 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::{anyhow, ensure};
|
||||
use openidconnect::{
|
||||
AccessToken, AccessTokenHash, AuthorizationCode, OAuth2TokenResponse, RefreshToken,
|
||||
RequestTokenError, SubjectIdentifier, TokenResponse, reqwest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
use super::{auth::AuthState, client::ClientWithConfig};
|
||||
|
||||
pub async fn exchange_code(
|
||||
client: &ClientWithConfig,
|
||||
auth_state: AuthState,
|
||||
http: &reqwest::Client,
|
||||
code: AuthorizationCode,
|
||||
) -> anyhow::Result<Option<OidcTokens>> {
|
||||
let token_response = match client
|
||||
.client
|
||||
.exchange_code(code)?
|
||||
.set_pkce_verifier(auth_state.pkce_verifier)
|
||||
.set_redirect_uri(Cow::Borrowed(&auth_state.callback_url))
|
||||
.request_async(http)
|
||||
.await
|
||||
{
|
||||
Ok(token_response) => token_response,
|
||||
Err(RequestTokenError::ServerResponse(err)) => {
|
||||
debug!("failed to exchange authorization code: {err:#}");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.ok_or_else(|| anyhow!("server did not return an id token"))?;
|
||||
let id_token_verifier = client.client.id_token_verifier();
|
||||
let claims = id_token.claims(&id_token_verifier, &auth_state.nonce)?;
|
||||
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(&id_token_verifier)?,
|
||||
)?;
|
||||
ensure!(
|
||||
actual_access_token_hash == *expected_access_token_hash,
|
||||
"invalid access token"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(OidcTokens {
|
||||
access_token: token_response.access_token().clone(),
|
||||
refresh_token: token_response.refresh_token().cloned(),
|
||||
sub: claims.subject().clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
refresh_token: &RefreshToken,
|
||||
client: &ClientWithConfig,
|
||||
http: &reqwest::Client,
|
||||
) -> anyhow::Result<OidcTokens> {
|
||||
let response = client
|
||||
.client
|
||||
.exchange_refresh_token(refresh_token)?
|
||||
.request_async(http)
|
||||
.await?;
|
||||
|
||||
let id_token = response
|
||||
.id_token()
|
||||
.ok_or_else(|| anyhow!("server did not return an id token"))?;
|
||||
let id_token_verifier = client.client.id_token_verifier();
|
||||
let claims = id_token.claims(&id_token_verifier, |_: Option<&_>| Ok(()))?;
|
||||
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
response.access_token(),
|
||||
id_token.signing_alg()?,
|
||||
id_token.signing_key(&id_token_verifier)?,
|
||||
)?;
|
||||
ensure!(
|
||||
actual_access_token_hash == *expected_access_token_hash,
|
||||
"invalid access token"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(OidcTokens {
|
||||
access_token: response.access_token().clone(),
|
||||
refresh_token: response.refresh_token().cloned(),
|
||||
sub: claims.subject().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OidcTokens {
|
||||
pub access_token: AccessToken,
|
||||
pub refresh_token: Option<RefreshToken>,
|
||||
pub sub: SubjectIdentifier,
|
||||
}
|
52
src/oidc/userinfo.rs
Normal file
52
src/oidc/userinfo.rs
Normal file
|
@ -0,0 +1,52 @@
|
|||
use openidconnect::{
|
||||
AccessToken, EndUserEmail, EndUserName, EndUserUsername, SubjectIdentifier, UserInfoClaims,
|
||||
core::CoreGenderClaim, reqwest,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{client::ClientWithConfig, custom_claims::CustomClaims};
|
||||
|
||||
pub async fn get_userinfo(
|
||||
access_token: AccessToken,
|
||||
client: &ClientWithConfig,
|
||||
http: &reqwest::Client,
|
||||
expected_sub: SubjectIdentifier,
|
||||
) -> anyhow::Result<UserInfo> {
|
||||
let claims: UserInfoClaims<CustomClaims, CoreGenderClaim> = client
|
||||
.client
|
||||
.user_info(access_token, Some(expected_sub))?
|
||||
.request_async(http)
|
||||
.await?;
|
||||
|
||||
let roles = client
|
||||
.config
|
||||
.roles_claim
|
||||
.as_ref()
|
||||
.and_then(|claim| claims.additional_claims().get(claim))
|
||||
.and_then(|claim| claim.as_array())
|
||||
.iter()
|
||||
.flat_map(|&roles| roles)
|
||||
.filter_map(|role| role.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(UserInfo {
|
||||
sub: claims.subject().clone(),
|
||||
name: claims
|
||||
.name()
|
||||
.and_then(|name| name.iter().next())
|
||||
.map(|(_, name)| name.clone()),
|
||||
username: claims.preferred_username().cloned(),
|
||||
email: claims.email().cloned(),
|
||||
roles,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserInfo {
|
||||
pub sub: SubjectIdentifier,
|
||||
pub name: Option<EndUserName>,
|
||||
pub username: Option<EndUserUsername>,
|
||||
pub email: Option<EndUserEmail>,
|
||||
pub roles: Vec<String>,
|
||||
}
|
23
src/utils.rs
Normal file
23
src/utils.rs
Normal file
|
@ -0,0 +1,23 @@
|
|||
use openidconnect::url::Url;
|
||||
|
||||
pub trait UrlExt {
|
||||
fn is_secure(&self) -> bool;
|
||||
}
|
||||
|
||||
impl UrlExt for Url {
|
||||
fn is_secure(&self) -> bool {
|
||||
self.scheme() == "https"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_secure() {
|
||||
assert!(Url::parse("https://example.com/").unwrap().is_secure());
|
||||
assert!(!Url::parse("http://example.com/").unwrap().is_secure());
|
||||
}
|
||||
}
|
46
treefmt.nix
Normal file
46
treefmt.nix
Normal file
|
@ -0,0 +1,46 @@
|
|||
{ lib, pkgs, ... }:
|
||||
|
||||
{
|
||||
tree-root-file = ".git/config";
|
||||
on-unmatched = "warn";
|
||||
|
||||
excludes = [
|
||||
"*.lock"
|
||||
"*.md"
|
||||
".envrc"
|
||||
".gitattributes"
|
||||
".gitignore"
|
||||
"Cargo.nix"
|
||||
];
|
||||
|
||||
formatter.nixfmt = {
|
||||
command = lib.getExe pkgs.nixfmt-rfc-style;
|
||||
includes = [ "*.nix" ];
|
||||
options = [ "--strict" ];
|
||||
};
|
||||
|
||||
formatter.prettier = {
|
||||
command = lib.getExe pkgs.nodePackages.prettier;
|
||||
includes = [ "*.yml" ];
|
||||
options = [ "--write" ];
|
||||
};
|
||||
|
||||
formatter.rustfmt = {
|
||||
command = lib.getExe pkgs.rustfmt;
|
||||
includes = [ "*.rs" ];
|
||||
options = [
|
||||
"--config=skip_children=true"
|
||||
"--edition=2024"
|
||||
];
|
||||
};
|
||||
|
||||
formatter.taplo = {
|
||||
command = lib.getExe pkgs.taplo;
|
||||
includes = [ "*.toml" ];
|
||||
options = [
|
||||
"format"
|
||||
"--option=column_width=120"
|
||||
"--option=align_comments=false"
|
||||
];
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue