This commit is contained in:
Felix Bargfeldt 2025-04-13 01:51:33 +02:00
commit abb8ee311f
Signed by: Defelo
GPG key ID: 2A05272471204DD3
35 changed files with 17604 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
Cargo.nix linguist-vendored

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/target
*_secret
socket
config.dev.yml
.nixos-test-history

3083
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

11562
Cargo.nix vendored Normal file

File diff suppressed because it is too large Load diff

37
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 users real ip\. If unset, the session is not bound to the users 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 users 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 users 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
pub mod cli;
mod config;
mod http;
mod oidc;
mod utils;

4
src/main.rs Normal file
View file

@ -0,0 +1,4 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
nginx_oidc::cli::main().await
}

43
src/oidc/auth.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
];
};
}