diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index f3e9cd75a950..77ced054b0fd 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -430,6 +430,7 @@ ./services/security/haveged.nix ./services/security/hologram.nix ./services/security/munge.nix + ./services/security/oauth2_proxy.nix ./services/security/physlock.nix ./services/security/torify.nix ./services/security/tor.nix diff --git a/nixos/modules/services/security/oauth2_proxy.nix b/nixos/modules/services/security/oauth2_proxy.nix new file mode 100644 index 000000000000..aa962743f855 --- /dev/null +++ b/nixos/modules/services/security/oauth2_proxy.nix @@ -0,0 +1,528 @@ +# NixOS module for oauth2_proxy. + +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.oauth2_proxy; + + # Use like: + # repeatedArgs (arg: "--arg=${arg}") args + repeatedArgs = concatMapStringsSep " "; + + # 'toString' doesn't quite do what we want for bools. + fromBool = x: if x then "true" else "false"; + + # oauth2_proxy provides many options that are only relevant if you are using + # a certain provider. This set maps from provider name to a function that + # takes the configuration and returns a string that can be inserted into the + # command-line to launch oauth2_proxy. + providerSpecificOptions = { + azure = cfg: '' + --azure-tenant=${cfg.azure.tenant} \ + --resource=${cfg.azure.resource} \ + ''; + + github = cfg: '' + $(optionalString (!isNull cfg.github.org) "--github-org=${cfg.github.org}") \ + $(optionalString (!isNull cfg.github.team) "--github-org=${cfg.github.team}") \ + ''; + + google = cfg: '' + --google-admin-email=${cfg.google.adminEmail} \ + --google-service-account=${cfg.google.serviceAccountJSON} \ + $(repeatedArgs (group: "--google-group=${group}") cfg.google.groups) \ + ''; + }; + + authenticatedEmailsFile = pkgs.writeText "authenticated-emails" cfg.email.addresses; + + getProviderOptions = cfg: provider: + if providerSpecificOptions ? provider then providerSpecificOptions.provider cfg else ""; + + mkCommandLine = cfg: '' + --provider='${cfg.provider}' \ + ${optionalString (!isNull cfg.email.addresses) "--authenticated-emails-file='${authenticatedEmailsFile}'"} \ + --approval-prompt='${cfg.approvalPrompt}' \ + ${optionalString (cfg.passBasicAuth && !isNull cfg.basicAuthPassword) "--basic-auth-password='${cfg.basicAuthPassword}'"} \ + --client-id='${cfg.clientID}' \ + --client-secret='${cfg.clientSecret}' \ + ${optionalString (!isNull cfg.cookie.domain) "--cookie-domain='${cfg.cookie.domain}'"} \ + --cookie-expire='${cfg.cookie.expire}' \ + --cookie-httponly=${fromBool cfg.cookie.httpOnly} \ + --cookie-name='${cfg.cookie.name}' \ + --cookie-secret='${cfg.cookie.secret}' \ + --cookie-secure=${fromBool cfg.cookie.secure} \ + ${optionalString (!isNull cfg.cookie.refresh) "--cookie-refresh='${cfg.cookie.refresh}'"} \ + ${optionalString (!isNull cfg.customTemplatesDir) "--custom-templates-dir='${cfg.customTemplatesDir}'"} \ + ${repeatedArgs (x: "--email-domain='${x}'") cfg.email.domains} \ + --http-address='${cfg.httpAddress}' \ + ${optionalString (!isNull cfg.htpasswd.file) "--htpasswd-file='${cfg.htpasswd.file}' --display-htpasswd-form=${fromBool cfg.htpasswd.displayForm}"} \ + ${optionalString (!isNull cfg.loginURL) "--login-url='${cfg.loginURL}'"} \ + --pass-access-token=${fromBool cfg.passAccessToken} \ + --pass-basic-auth=${fromBool cfg.passBasicAuth} \ + --pass-host-header=${fromBool cfg.passHostHeader} \ + --proxy-prefix='${cfg.proxyPrefix}' \ + ${optionalString (!isNull cfg.profileURL) "--profile-url='${cfg.profileURL}'"} \ + ${optionalString (!isNull cfg.redeemURL) "--redeem-url='${cfg.redeemURL}'"} \ + ${optionalString (!isNull cfg.redirectURL) "--redirect-url='${cfg.redirectURL}'"} \ + --request-logging=${fromBool cfg.requestLogging} \ + ${optionalString (!isNull cfg.scope) "--scope='${cfg.scope}'"} \ + ${repeatedArgs (x: "--skip-auth-regex='${x}'") cfg.skipAuthRegexes} \ + ${optionalString (!isNull cfg.signatureKey) "--signature-key='${cfg.signatureKey}'"} \ + --upstream='${cfg.upstream}' \ + ${optionalString (!isNull cfg.validateURL) "--validate-url='${cfg.validateURL}'"} \ + ${optionalString cfg.tls.enable "--tls-cert='${cfg.tls.certificate}' --tls-key='${cfg.tls.key}' --https-address='${cfg.tls.httpsAddress}'"} \ + '' + getProviderOptions cfg cfg.provider; +in +{ + options.services.oauth2_proxy = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to run oauth2_proxy. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.oauth2_proxy; + description = '' + The package that provides oauth2_proxy. + ''; + }; + + ############################################## + # PROVIDER configuration + provider = mkOption { + type = types.enum [ + "google" + "github" + "azure" + "gitlab" + "linkedin" + "myusa" + ]; + default = "google"; + description = '' + OAuth provider. + ''; + }; + + approvalPrompt = mkOption { + type = types.enum ["force" "auto"]; + default = "force"; + description = '' + OAuth approval_prompt. + ''; + }; + + clientID = mkOption { + type = types.str; + description = '' + The OAuth Client ID. + ''; + example = "123456.apps.googleusercontent.com"; + }; + + clientSecret = mkOption { + type = types.str; + description = '' + The OAuth Client Secret. + ''; + }; + + skipAuthRegexes = mkOption { + type = types.listOf types.str; + default = []; + description = '' + List of regular expressions which will bypass authentication when + requests path's match. + ''; + }; + + # XXX: Not clear whether these two options are mutually exclusive or not. + email = { + domains = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Authenticate emails with the specified domains. Use * to authenticate any email. + ''; + }; + + addresses = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Line-separated email addresses that are allowed to authenticate. + ''; + }; + }; + + loginURL = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Authentication endpoint. + + You only need to set this if you are using a self-hosted provider (e.g. + Github Enterprise). If you're using a publicly hosted provider + (e.g github.com), then the default works. + ''; + example = "https://provider.example.com/oauth/authorize"; + }; + + redeemURL = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Token redemption endpoint. + + You only need to set this if you are using a self-hosted provider (e.g. + Github Enterprise). If you're using a publicly hosted provider + (e.g github.com), then the default works. + ''; + example = "https://provider.example.com/oauth/token"; + }; + + validateURL = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Access token validation endpoint. + + You only need to set this if you are using a self-hosted provider (e.g. + Github Enterprise). If you're using a publicly hosted provider + (e.g github.com), then the default works. + ''; + example = "https://provider.example.com/user/emails"; + }; + + redirectURL = mkOption { + # XXX: jml suspects this is always necessary, but the command-line + # doesn't require it so making it optional. + type = types.nullOr types.str; + default = null; + description = '' + The OAuth2 redirect URL. + ''; + example = "https://internalapp.yourcompany.com/oauth2/callback"; + }; + + azure = { + tenant = mkOption { + type = types.str; + default = "common"; + description = '' + Go to a tenant-specific or common (tenant-independent) endpoint. + ''; + }; + + resource = mkOption { + type = types.str; + description = '' + The resource that is protected. + ''; + }; + }; + + google = { + adminEmail = mkOption { + type = types.str; + description = '' + The Google Admin to impersonate for API calls. + + Only users with access to the Admin APIs can access the Admin SDK + Directory API, thus the service account needs to impersonate one of + those users to access the Admin SDK Directory API. + + See + ''; + }; + + groups = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Restrict logins to members of these Google groups. + ''; + }; + + serviceAccountJSON = mkOption { + type = types.path; + description = '' + The path to the service account JSON credentials. + ''; + }; + }; + + github = { + org = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Restrict logins to members of this organisation. + ''; + }; + + team = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Restrict logins to members of this team. + ''; + }; + }; + + + #################################################### + # UPSTREAM Configuration + upstream = mkOption { + type = types.commas; + description = '' + The http url(s) of the upstream endpoint or file:// paths for static + files. Routing is based on the path. + ''; + }; + + passAccessToken = mkOption { + type = types.bool; + default = false; + description = '' + Pass OAuth access_token to upstream via X-Forwarded-Access-Token header. + ''; + }; + + passBasicAuth = mkOption { + type = types.bool; + default = true; + description = '' + Pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream. + ''; + }; + + basicAuthPassword = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The password to set when passing the HTTP Basic Auth header. + ''; + }; + + passHostHeader = mkOption { + type = types.bool; + default = true; + description = '' + Pass the request Host Header to upstream. + ''; + }; + + signatureKey = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + GAP-Signature request signature key. + ''; + example = "sha1:secret0"; + }; + + cookie = { + domain = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + An optional cookie domain to force cookies to. + ''; + example = ".yourcompany.com"; + }; + + expire = mkOption { + type = types.str; + default = "168h0m0s"; + description = '' + Expire timeframe for cookie. + ''; + }; + + httpOnly = mkOption { + type = types.bool; + default = true; + description = '' + Set HttpOnly cookie flag. + ''; + }; + + name = mkOption { + type = types.str; + default = "_oauth2_proxy"; + description = '' + The name of the cookie that the oauth_proxy creates. + ''; + }; + + refresh = mkOption { + # XXX: Unclear what the behavior is when this is not specified. + type = types.nullOr types.str; + default = null; + description = '' + Refresh the cookie after this duration; 0 to disable. + ''; + example = "168h0m0s"; + }; + + secret = mkOption { + type = types.str; + description = '' + The seed string for secure cookies. + ''; + }; + + secure = mkOption { + type = types.bool; + default = true; + description = '' + Set secure (HTTPS) cookie flag. + ''; + }; + }; + + #################################################### + # OAUTH2 PROXY configuration + + httpAddress = mkOption { + type = types.str; + default = "127.0.0.1:4180"; + description = '' + [http://]: or unix:// to listen on for HTTP clients. + + This module does *not* expose the port by default. If you want this URL + to be accessible to other machines, please add the port to + networking.firewall.allowedTCPPorts. + ''; + }; + + htpasswd = { + file = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Additionally authenticate against a htpasswd file. Entries must be + created with "htpasswd -s" for SHA encryption. + ''; + }; + + displayForm = mkOption { + type = types.bool; + default = true; + description = '' + Display username / password login form if an htpasswd file is provided. + ''; + }; + }; + + customTemplatesDir = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to custom HTML templates. + ''; + }; + + proxyPrefix = mkOption { + type = types.str; + default = "/oauth2"; + description = '' + The url root path that this proxy should be nested under (e.g. //sign_in); + ''; + }; + + tls = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to serve over TLS. + ''; + }; + + certificate = mkOption { + type = types.path; + description = '' + Path to certificate file. + ''; + }; + + key = mkOption { + type = types.path; + description = '' + Path to private key file. + ''; + }; + + httpsAddress = mkOption { + type = types.str; + default = ":443"; + description = '' + : to listen on for HTTPS clients. + + Remember to add to allowedTCPPorts if you want other machines + to be able to connect to it. + ''; + }; + }; + + requestLogging = mkOption { + type = types.bool; + default = true; + description = '' + Log requests to stdout. + ''; + }; + + #################################################### + # UNKNOWN + + # XXX: Is this mandatory? Is it part of another group? Is it part of the provider specification? + scope = mkOption { + # XXX: jml suspects this is always necessary, but the command-line + # doesn't require it so making it optional. + type = types.nullOr types.str; + default = null; + description = '' + OAuth scope specification. + ''; + }; + + profileURL = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Profile access endpoint. + ''; + }; + + }; + + config = mkIf cfg.enable { + + users.extraUsers.oauth2_proxy = { + description = "OAuth2 Proxy"; + }; + + systemd.services.oauth2_proxy = { + description = "OAuth2 Proxy"; + path = [ cfg.package ]; + wantedBy = [ "multi-user.target" ]; + after = [ "network-interfaces.target" ]; + + serviceConfig = { + User = "oauth2_proxy"; + Restart = "always"; + ExecStart = "${cfg.package}/bin/oauth2_proxy ${mkCommandLine cfg}"; + }; + }; + + }; +}