{ pkgs, lib, config, sources, ... }: let cfg = config.mods.server.auth2api; auth2api-pkg = pkgs.callPackage ../../../pkgs/auth2api/package.nix { inherit sources; }; # Wrap the binary so --config is always implicit, whether run by the # service, by the user for --login, or any other invocation. auth2api-wrapped = pkgs.symlinkJoin { name = "auth2api-wrapped"; paths = [ auth2api-pkg ]; buildInputs = [ pkgs.makeWrapper ]; postBuild = '' wrapProgram $out/bin/auth2api \ --add-flags "--config=${cfg.stateDir}/config.yaml" \ --run "umask 027" ''; }; # Nix-managed config template — read-only in /nix/store. # systemd-tmpfiles copies it into stateDir on first boot (C rule), # so auth2api can write back to it (e.g. API key auto-generation) # and the user can hand-edit it freely without rebuilds overwriting it. configTemplate = pkgs.writeText "auth2api-config-template.yaml" '' host: "${cfg.host}" port: ${toString cfg.port} # Directory where OAuth token files are stored (written by --login). auth-dir: "${cfg.stateDir}/tokens" # Empty = unauthenticated mode (safe when binding to 127.0.0.1). api-keys: [] body-limit: "${cfg.bodyLimit}" timeouts: messages-ms: ${toString cfg.timeouts.messagesMs} stream-messages-ms: ${toString cfg.timeouts.streamMessagesMs} count-tokens-ms: ${toString cfg.timeouts.countTokensMs} stats: enabled: true # off | errors | verbose debug: "${cfg.debug}" thinking: # Default reasoning effort for requests without reasoning_effort set. # none | minimal | low | medium | high | xhigh default-effort: "${cfg.thinking.defaultEffort}" cloaking: cli-version: "2.1.88" entrypoint: "cli" ''; in with lib; { options.mods.server.auth2api = { enable = mkEnableOption "auth2api OAuth-to-API proxy"; port = mkOption { type = types.port; default = 8317; description = "Port auth2api listens on."; }; host = mkOption { type = types.str; default = "127.0.0.1"; description = "Bind address. Defaults to localhost — safe with no API key."; }; stateDir = mkOption { type = types.str; default = "/var/lib/auth2api"; description = '' State directory. Contains config.yaml (live, writable) and tokens/ (OAuth token files written by --login). ''; }; bodyLimit = mkOption { type = types.str; default = "200mb"; description = "Maximum JSON request body size."; }; timeouts = { messagesMs = mkOption { type = types.int; default = 120000; description = "Non-streaming /v1/messages timeout (ms)."; }; streamMessagesMs = mkOption { type = types.int; default = 600000; description = "Streaming /v1/messages timeout (ms)."; }; countTokensMs = mkOption { type = types.int; default = 30000; description = "/v1/messages/count_tokens timeout (ms)."; }; }; debug = mkOption { type = types.enum [ "off" "errors" "verbose" ]; default = "off"; description = "Log level: off | errors | verbose."; }; thinking = { defaultEffort = mkOption { type = types.enum [ "none" "minimal" "low" "medium" "high" "xhigh" ]; default = "medium"; description = '' Default Claude extended-thinking budget injected into every request that doesn't already carry a reasoning_effort field. none = thinking disabled minimal = 512 tokens low = 1 024 tokens medium = 8 192 tokens (default) high = 24 576 tokens xhigh = 32 768 tokens ''; }; }; openFirewall = mkOption { type = types.bool; default = false; description = "Open the firewall for the auth2api port."; }; }; config = mkIf cfg.enable { # tmpfiles runs as root before any service starts. # 'd' creates the directory if missing; 'C' copies the template into # config.yaml only if that file doesn't already exist — so hand-edits # and auth2api's own writes are never clobbered on rebuild. systemd.tmpfiles.rules = [ # stateDir: world-traversable so any local user can reach files inside "d '${cfg.stateDir}' 0755 auth2api auth2api - -" "z '${cfg.stateDir}' 0755 auth2api auth2api - -" # tokens: group-writable so members of the auth2api group can run --login "d '${cfg.stateDir}/tokens' 0770 auth2api auth2api - -" "z '${cfg.stateDir}/tokens' 0770 auth2api auth2api - -" # config.yaml: contains no secrets (empty api-keys), safe to be world-readable "C '${cfg.stateDir}/config.yaml' 0644 auth2api auth2api - ${configTemplate}" "z '${cfg.stateDir}/config.yaml' 0644 auth2api auth2api - -" ]; # auth2api.path watches the tokens directory and activates auth2api.service # the moment a token file appears (i.e. after --login has been run). # This means the service is never started — and never fails — during # activation on a fresh machine, so nixos-rebuild always succeeds cleanly. systemd.paths.auth2api = { description = "Watch for auth2api OAuth tokens"; wantedBy = [ "multi-user.target" ]; after = [ "systemd-tmpfiles-setup.service" ]; pathConfig = { # Trigger when any file is created inside the tokens directory. DirectoryNotEmpty = "${cfg.stateDir}/tokens"; # Re-trigger if the service stops (e.g. after a logout / token removal). Unit = "auth2api.service"; }; }; systemd.services.auth2api = { description = "auth2api OAuth-to-API proxy"; # Started by the path unit, not directly by multi-user.target. # Must wait for tmpfiles so config.yaml is always present before start. after = [ "network.target" "systemd-tmpfiles-setup.service" ]; requires = [ "systemd-tmpfiles-setup.service" ]; serviceConfig = { Type = "simple"; ExecStart = "${auth2api-wrapped}/bin/auth2api"; Restart = "on-failure"; RestartSec = "10s"; User = "auth2api"; Group = "auth2api"; ReadWritePaths = [ cfg.stateDir ]; # Hardening NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "strict"; ProtectHome = true; PrivateDevices = true; ProtectKernelTunables = true; ProtectControlGroups = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; LockPersonality = true; MemoryDenyWriteExecute = false; # Node.js JIT requires W+X pages RestrictRealtime = true; RestrictSUIDSGID = true; }; }; users.users.auth2api = { isSystemUser = true; group = "auth2api"; home = cfg.stateDir; description = "auth2api service user"; }; users.groups.auth2api = { }; # Allow the primary user to run --login and read/write tokens. users.users.${config.mods.user.name}.extraGroups = [ "auth2api" ]; # Make the wrapped binary available in PATH for `auth2api --login` etc. environment.systemPackages = [ auth2api-wrapped ]; networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; }; }