idcoop 0.0.1

Simple identity server (user login manager) supporting OpenID Connect (OAuth 2.0). Can be used for your own simple SSO system or so you don't have to write a login system for your software. [application crate, not a library]
Documentation
flake: { config, pkgs, ... }:
let
  inherit (flake.packages.${pkgs.stdenv.hostPlatform.system}) idcoop;
  inherit (pkgs.lib) mkOption mkDefault mkIf types literalExpression mdDoc;
  inherit (pkgs.writers) writeTOML;
  inherit (builtins) mapAttrs;

  defaultUser = "idcoop";

  cfg = config.services.idcoop;
  format = pkgs.formats.toml { };

  oidcClientSubmodule = types.submodule {
    # freeformType = format.type; — one day we may want to enable freeform types, but for now just keep it strongly defined
    options = {
      name = mkOption {
        type = types.str;
        description = ''
          User-friendly name of the OIDC Client.
        '';
      };
      redirect_uris = mkOption {
        type = types.listOf types.str;
        description = ''
          List of redirect URIs that the client can use to redirect login attempts back to itself.
          Consult the documentation for the other service if you aren't sure.
        '';
      };
      allow_user_classes = mkOption {
        type = types.listOf types.str;
        description = ''
          List of user classes which are authorised (allowed) to use this client (access this service).
          As of idCoop v0.0.1, this setting is unimplemented and has no effect.
        '';
      };
    };
  };
in
{
  options.services.idcoop = {
    enable = mkOption {
      type = types.bool;
      default = false;
      description = ''
        Whether to enable idCoop, a simple identity provider.
      '';
    };

    configurePostgres = mkOption {
      type = types.bool;
      default = true;
      description = ''
        Whether to configure a Postgres database for idCoop.
        Enabled by default.
      '';
    };
    
    user = mkOption {
      type = types.str;
      default = defaultUser;
      description = ''
        User to run the service as.
        Will be created automatically if it is left at the default.
      '';
    };

    group = mkOption {
      type = types.str;
      default = defaultUser;
      description = ''
        User to run the service as.
        Will be created automatically if it is left at the default.
      '';
    };

    secretsPath = mkOption {
      type = types.path;
      default = builtins.toFile "blank.toml" "";
      description = ''
        Path to a file containing secrets. This file should be kept out of the Nix store.
        Consult the idCoop documentation for the format of the secrets file.
      '';
    };

    settings = mkOption {
      default = {};
      description = "idCoop configuration.";
      
      type = types.submodule {
        # freeformType = format.type; — one day we may want to enable freeform types, but for now just keep it strongly defined
        options = {
          listen = {
            bind = mkOption {
              type = types.str;
              default = "127.0.0.1:8072";
              description = ''
                Host and port combination upon which to bind the web interface.
              '';
            };
            public_base_uri = mkOption {
              type = types.str;
              default = "http://${cfg.settings.listen.bind}";
              defaultText = "`http://{listen.bind}`";
              description = ''
                Public-facing HTTP(S) base URL.
              '';
            };
          };

          oidc = {
            issuer = mkOption {
              type = types.str;
              default = cfg.settings.listen.public_base_uri;
              defaultText = "`listen.public_base_uri`";
              description = ''
                The identity provider's 'issuer' identifier, as used in OpenID Connect.
                This should be configured in clients (relying parties) and should likely not be changed.
              '';
            };

            rsa_keypair = mkOption {
              type = types.path;
              description = ''
                Path to an RSA keypair used for signing JSON Web Tokens.
              '';
            };


            clients = mkOption {
              type = types.attrsOf oidcClientSubmodule;
              default = {};
              description = ''
                OpenID Connect 'clients' (also known as relying parties).
                These entries are for the different services you want users to be able to log in to using idCoop.
              '';
            };
          };

          postgres = {
            connect = mkOption {
              type = types.str;
              default = "postgres:";
              description = mdDoc ''
                Connection string for the Postgres database. The default of `postgres:` uses the [libpq environment variables] to form a connection;
                usually this by default connects to the local UNIX socket with the current user's name as a username and database name,
                if no environment variables are set.

                [libpq environment variables]: https://www.postgresql.org/docs/current/libpq-envars.html
              '';
            };
          };
        };
      };
    };
  };

  config = let
    configPath = writeTOML "idcoop_config.toml" cfg.settings;
  in {
    users.users.idcoop = mkIf (cfg.enable && cfg.user == defaultUser) {
      isSystemUser = true;
      group = cfg.group;
      home = mkDefault "/var/lib/idcoop";
      createHome = true;

      packages = [
        # Add a wrapper for the idcoop command so the user can use the CLI conveniently
        (pkgs.writeShellScriptBin "idcoop" ''
          IDCOOP_CONFIG=${pkgs.lib.escapeShellArg configPath} IDCOOP_SECRETS=${pkgs.lib.escapeShellArg cfg.secretsPath} exec ${idcoop}/bin/idcoop "$@"
        '')
      ];
    };
    users.groups.idcoop = mkIf (cfg.enable && cfg.group == defaultUser) {};

    systemd.services.idcoop = mkIf cfg.enable {
      description = "idCoop: simple identity provider";
      wantedBy = [ "multi-user.target" ];
      after = [ "networking.target" "network-online.target" "postgresql.service" ];

      serviceConfig =
        {
          ExecStart = "${idcoop}/bin/idcoop --config ${pkgs.lib.escapeShellArg configPath} --secrets ${pkgs.lib.escapeShellArg cfg.secretsPath} serve";
          User = cfg.user;
          Group = cfg.group;
        };
    };

    services.postgresql = mkIf cfg.configurePostgres {
      ensureUsers = [
          {
            name = "idcoop";
            ensureDBOwnership = true;
          }
        ];
      ensureDatabases = ["idcoop"];
    };
  };
}