[
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  pull_request:\n\njobs:\n  rustfmt:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v3\n    - run: rustup update stable && rustup default stable\n    - run: rustup component add rustfmt\n    - run: cargo fmt --all --check\n\n  test:\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        include:\n        - name: Linux x86_64 stable\n          os: ubuntu-latest\n          rust: stable\n          other: i686-unknown-linux-gnu\n        - name: Linux x86_64 beta\n          os: ubuntu-latest\n          rust: beta\n          other: i686-unknown-linux-gnu\n        - name: Linux x86_64 nightly\n          os: ubuntu-latest\n          rust: nightly\n          other: i686-unknown-linux-gnu\n        - name: macOS x86_64 stable\n          os: macos-latest\n          rust: stable\n          other: x86_64-apple-ios\n    name: Tests ${{ matrix.name }}\n    steps:\n    - uses: actions/checkout@v3\n    - run: rustup update stable && rustup default stable\n    - name: debug_tests\n      run: cargo test\n    - name: release_tests\n      run: cargo test --release\n"
  },
  {
    "path": ".gitignore",
    "content": "target/\n"
  },
  {
    "path": "CHANGES.md",
    "content": "# pizauth 1.1.0 (2026-XX-XX)\n\n* `auth_notify_cmd` is now subject to a 10 second timeout. If you previously\n  relied on the command being run taking an arbitrary amount of time, you will\n  need to run it to ensure it is not affected by the timeout.\n\n\n# pizauth 1.0.11 (2026-03-10)\n\n* Mark certain HTTP error codes as transitory, allowing pizauth to retry rather\n  than immediately give up.\n\n\n# pizauth 1.0.10 (2026-02-25)\n\n* Remove `tmppath` pledge on OpenBSD. This doesn't do anything useful in\n  pizauth, and support for it will be removed in OpenBSD.\n\n\n# pizauth 1.0.9 (2025-12-13)\n\n* Update dependencies with breaking changes.\n\n\n# pizauth 1.0.8 (2025-11-02)\n\n* Add `startup_cmd` configuration option: this shell command is run after\n  pizauth's server is fully up and running. It can be used, for example, as a\n  callback, safe in the knowledge that the server can respond.\n\n* Add Fish completion.\n\n\n# pizauth 1.0.7 (2025-02-08)\n\n* Add an upper limit to the size of HTTP requests pizauth accepts. In the\n  (admittedly rather unlikely) case that a client malfunctions, this makes it\n  more difficult for them to accidentally cause pizauth to run out of memory.\n\n* Wake up the refresher / notifier periodically (currently every 37 seconds) to\n  see if there is work to do. This is a crude workaround for inconsistency in\n  operating systems as to whether \"wake up in X seconds\" includes time spent in\n  suspend/hibernate or adjusted by `adjtime` and so on.\n\n* Present times (e.g. about expired tokens) in a way that is less likely to be\n  skewed by suspend/hibernate on some operating systems.\n\n\n# pizauth 1.0.6 (2024-11-10)\n\n* Support HTTPS redirects. pizauth now starts, by default, both HTTP and HTTPS\n  servers, generating a new self-signed HTTPS certificate on each invocation.\n\n  `pizauth info` shows you the certificate hash so you can verify that your\n  browser is connecting to the right HTTPS server. You can turn either (but\n  not both!) of the HTTP and HTTPS servers off with `http_listen=off` or\n  `https_listen=off`. This is most useful if you want to force HTTPS redirects\n  and ensure that you're not accidentally being redirected to an HTTP URL (i.e.\n  `http_listen=off` is the option you are most likely to be interested in).\n\n\n# pizauth 1.0.5 (2024-07-27)\n\n* Use `XDG_RUNTIME_DIR` instead of `XDG_DATA_HOME` for the socket path.\n\n\n# pizauth 1.0.4 (2024-02-04)\n\n* Add `pizauth revoke` which revokes any tokens / ongoing authentication for a\n  given account. Note that this does *not* revoke the remote token, as OAuth2\n  does not currently have standard support for this.\n\n* Include bash completion scripts and example systemd units.\n\n* Rework file installation to better handle a variety of OSes and file layouts.\n  The Makefile is now only compatible with gmake.\n\n\n# pizauth 1.0.3 (2023-11-28)\n\n* Add `pizauth status` to see which accounts have valid tokens (or not):\n  ```\n  $ pizauth status\n  act1: No access token\n  act2: Active access token (obtained Sat, 4 Nov 2023 21:52:11 +0000; expires Sat, 4 Nov 2023 23:20:42 +0000)\n  act3: Active access token (obtained Sat, 4 Nov 2023 22:23:03 +0000; expires Mon, 4 Dec 2023 22:23:03 +0000)\n  ```\n\n* Give better syntax errors if backslashes (`\\\\`) are used incorrectly.\n\n\n# pizauth 1.0.2 (2023-10-12)\n\n* Better handle syntactically invalid requests. In particular, this causes the\n  check for a running instance of pizauth not to cause the existing instance to\n  exit.\n\n* Document `-v` (which can be repeated up to 4 times for increased verbosity)\n  on `pizauth server`.\n\n\n# pizauth 1.0.1 (2023-08-14)\n\n* Fix location of `ring` dependency.\n\n\n# pizauth 1.0.0 (2023-08-13)\n\n* First stable release.\n\n* Add `pizauth info [-j]` which informs the user where pizauth is looking for\n  configuration files and so on. For example:\n\n  ```\n  $ pizauth info\n  pizauth version 0.3.0:\n    cache directory: /home/ltratt/.cache/pizauth\n    config file: /home/ltratt/.config/pizauth.conf\n  ```\n\n  Adding `-j` outputs the same information in JSON format for integration with\n  external tools. The `info_format_version` field is an integer value\n  specifying the version of the JSON output: if incompatible changes are made,\n  this integer will be monotonically increased.\n\n\n# pizauth 0.3.0 (2023-05-29)\n\n## Breaking changes\n\n* `not_transient_error_if` has been removed as a global and per-account option.\n  In its place is a new global option `transient_error_if_cmd`.\n\n  If you have an existing `not_transient_error_if` option, you will need\n  to reconsider the shell command you execute. One possibility is to change:\n\n  ```\n  not_transient_error_if = \"shell-cmd\";\n  ```\n  \n  to:\n  \n  ```\n  transient_error_if_cmd = \"! shell-cmd\";\n  ```\n\n  `transient_error_if_cmd` sets the environment variable `$PIZAUTH_ACCOUNT` to\n  allow you to perform different actions for different accounts if you desire.\n\n\n# pizauth 0.2.2 (2023-04-03)\n\n* Added a global `token_event_cmd` option, which runs a command whenever an\n  account's token changes state.\n\n* Added `pizauth dump` and `pizauth restore` which dump and restore pizauth's\n  token state respectively. When combined with `token_event_cmd`, these allow\n  users to persist token state. The output from `pizauth dump` is not\n  meaningfully encrypted: it is the user's responsibility to encrypt, or\n  otherwise secure, the dump output.\n\n* Update an account's refresh token if it is sent to pizauth by the remote\n  server.\n\n* Move from the unmaintained `json` to the `serde_json` crate.\n\n* Don't bother users with OAuth2 \"errors\" (e.g. that a token cannot be\n  refreshed) that are better thought of as an inevitable part of the OAuth2\n  lifecycle.\n\n\n# pizauth 0.2.1 (2023-03-11)\n\n* `login_hint` is now deprecated in favour of the more general `auth_uri_fields`.\n  Change:\n\n  ```\n  login_hint = \"email@example.com\";\n  ```\n  \n  to:\n  \n  ```\n  auth_uri_fields = { \"login_hint\": \"email@example.com\" };\n  ```\n\n  Currently `login_hint` is silently transformed into the equivalent\n  `auth_uri_fields` for backwards compatibility.\n\n* `auth_uri_fields` allows users to specify zero or more key/value pairs to be\n  appended to the authorisation URI. Keys (and their values) are appended\n  in the order they appear in `auth_uri_fields`, each separated by a `&`. The\n  same key may be specified multiple times.\n\n* Several options can now be set globally and overridden in individual accounts:\n    * `not_transient_error_if`\n    * `refresh_at_least`\n    * `refresh_before_expiry`\n    * `refresh_retry`\n\n* `scopes` is now optional and also, equivalently, can be empty.\n\n# pizauth 0.2.0 (2022-12-14)\n\n## Breaking changes\n\n* `auth_error_cmd` has been renamed to `error_notify_cmd`. pizauth detects the\n  (now) incorrect usage and informs the user.\n\n* `refresh_retry_interval` has been renamed to `refresh_retry` and moved from a\n  global to a per-account option. Its default value remains unchanged.\n\n## Other changes\n\n* Tease out transitory / permanent refresh errors. Transitory errors are likely\n  to be the result of temporary network problems and simply waiting for them to\n  resolve is normally the best thing to do. By default, pizauth thus simply\n  ignores transitory errors.\n\n  Users who wish to check that transitory errors really are transitory can set\n  `not_transitory_error_if` setting. This is a shell command that, if\n  it returns a zero exit code, signifies that transitory errors are\n  permanent and that an access token should be invalidated. `nc -z <website>\n  <port>` is an example of a reasonable setting. `not_transitory_error_if` is\n  fail-safe in that if the shell command fails unnecessarily (e.g. if you\n  specify `ping` on a network that prevents ping traffic), pizauth will\n  invalidate the access token.\n\n* Each refresh of an account now happens in a separate thread, so stalled\n  refreshing cannot affect other accounts.\n\n* Fix bug where newly authorised access tokens were immediately refreshed.\n\n\n# pizauth 0.1.1 (2022-10-20)\n\nSecond alpha release.\n\n* Added global `http_listen` option to fix the IP address and port pizauth's\n  HTTP server listens on. This is particularly useful when running pizauth on a\n  remote machine, since it makes it easy to open an `ssh -L` tunnel to\n  authenticate that remote instance.\n\n* Fix build on OS X by ignoring deprecation warnings for the `daemon` function.\n\n* Report config errors before daemonisation.\n\n\n# pizauth 0.1.0 (2022-09-29)\n\nFirst alpha release.\n"
  },
  {
    "path": "COPYRIGHT",
    "content": "Except as otherwise noted (below and/or in individual files), this project is\nlicensed under the Apache License, Version 2.0 <LICENSE-APACHE>\n<http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT>\n<http://opensource.org/licenses/MIT>, at your option.\n\nCopyright is retained by contributors and/or the organisations they\nrepresent(ed) -- this project does not require copyright assignment. Please see\nversion control history for a full list of contributors. Note that some files\nmay include explicit copyright and/or licensing notices.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"pizauth\"\ndescription = \"Command-line OAuth2 authentication daemon\"\nversion = \"1.0.11\"\nrepository = \"https://github.com/ltratt/pizauth/\"\nauthors = [\"Laurence Tratt <laurie@tratt.net>\"]\nreadme = \"README.md\"\nlicense = \"Apache-2.0 OR MIT\"\ncategories = [\"authentication\"]\nkeywords = [\"oauth\", \"oauth2\", \"authentication\"]\nedition = \"2021\"\n\n[build-dependencies]\ncfgrammar = \"0.14\"\nlrlex = \"0.14\"\nlrpar = \"0.14\"\nrerun_except = \"1\"\n\n[dependencies]\nbase64 = \"0.22\"\nboot-time = \"0.1.2\"\ncfgrammar = \"0.14\"\nchacha20poly1305 = \"0.10\"\nchrono = \"0.4\"\ngetopts = \"0.2\"\nhostname = \"0.4\"\nlog = \"0.4\"\nlrlex = \"0.14\"\nlrpar = \"0.14\"\nnix = { version=\"0.31.2\", features=[\"fs\", \"signal\"] }\nrand = \"0.10.1\"\nserde = { version=\"1.0\", features=[\"derive\"] }\nsha2 = \"0.11.0\"\nserde_json = \"1\"\nstderrlog = \"0.6\"\nsyslog = \"7.0.0\"\nureq = \"3\"\nurl = \"2\"\nwait-timeout = \"0.2\"\nwhoami = \"2.1.2\"\nrustls = { version = \"0.23.12\", features = [\"ring\", \"std\"], default-features = false }\nrcgen = { version = \"0.14.5\", features = [\"crypto\", \"ring\"], default-features = false }\nwincode = { version = \"0.5.3\", features = [\"derive\"] }\n\n[target.'cfg(target_os=\"openbsd\")'.dependencies]\npledge = \"0.4\"\nunveil = \"0.3\"\n\n[target.'cfg(target_os=\"macos\")'.dependencies]\nlibc = \"0.2\"\n\n[dev-dependencies]\ntempfile = \"3.27.0\"\n\n[profile.release]\nopt-level = 3\ndebug = false\nrpath = false\nlto = true\ndebug-assertions = false\ncodegen-units = 1\npanic = 'abort'\nincremental = false\noverflow-checks = true\n"
  },
  {
    "path": "LICENSE-APACHE",
    "content": "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use\nthis file except in compliance with the License.  You may obtain a copy of the\nLicense at\n\n    http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software distributed\nunder the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR\nCONDITIONS OF ANY KIND, either express or implied.  See the License for the\nspecific language governing permissions and limitations under the License.\n"
  },
  {
    "path": "LICENSE-MIT",
    "content": "Permission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies\nof the Software, and to permit persons to whom the Software is furnished to do\nso, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "PREFIX ?= /usr/local\nBINDIR ?= ${PREFIX}/bin\nLIBDIR ?= ${PREFIX}/lib\nSHAREDIR ?= ${PREFIX}/share\nEXAMPLESDIR ?= ${SHAREDIR}/examples\n\nMANDIR.${PREFIX} = ${PREFIX}/share/man\nMANDIR./usr/local = /usr/local/man\nMANDIR. = /usr/share/man\nMANDIR ?= ${MANDIR.${PREFIX}}\n\n.PHONY: all install test distrib\n\nall: target/release/pizauth\n\ntarget/release/pizauth:\n\tcargo build --release\n\nRUNNINGSYSTEMD=$(shell test -d /run/systemd/system/ && echo yes || echo no)\nifeq ($(USESYSTEMD), 0)\n\tINSTALLSYSTEMD :=\nelse ifneq ($(RUNNINGSYSTEMD), yes)\n\tINSTALLSYSTEMD :=\nelse\n\tINSTALLSYSTEMD := install-systemd\nendif\n\ninstall: target/release/pizauth ${INSTALLSYSTEMD}\n\tinstall -d ${DESTDIR}${BINDIR}\n\tinstall -c -m 555 target/release/pizauth ${DESTDIR}${BINDIR}/pizauth\n\tinstall -d ${DESTDIR}${MANDIR}/man1\n\tinstall -d ${DESTDIR}${MANDIR}/man5\n\tinstall -c -m 444 pizauth.1 ${DESTDIR}${MANDIR}/man1/pizauth.1\n\tinstall -c -m 444 pizauth.conf.5 ${DESTDIR}${MANDIR}/man5/pizauth.conf.5\n\tinstall -d ${DESTDIR}${EXAMPLESDIR}/pizauth\n\tinstall -c -m 444 examples/pizauth.conf ${DESTDIR}${EXAMPLESDIR}/pizauth/pizauth.conf\n\tinstall -d ${DESTDIR}${SHAREDIR}/bash-completion/completions\n\tinstall -c -m 444 share/bash/completion.bash ${DESTDIR}${SHAREDIR}/bash-completion/completions/pizauth\n\tinstall -d ${DESTDIR}${SHAREDIR}/fish/vendor_completions.d\n\tinstall -c -m 444 share/fish/pizauth.fish ${DESTDIR}${SHAREDIR}/fish/vendor_completions.d\n\tinstall -d ${DESTDIR}${SHAREDIR}/zsh/site-functions\n\tinstall -c -m 444 share/zsh/_pizauth ${DESTDIR}${SHAREDIR}/zsh/site-functions/_pizauth\n\ninstall-systemd:\n\tinstall -d ${DESTDIR}${LIBDIR}/systemd/user\n\tinstall -c -m 444 lib/systemd/user/pizauth.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth.service\n\tinstall -c -m 444 lib/systemd/user/pizauth-state-creds.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-creds.service\n\tinstall -c -m 444 lib/systemd/user/pizauth-state-age.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-age.service\n\tinstall -c -m 444 lib/systemd/user/pizauth-state-gpg.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-gpg.service\n\tinstall -c -m 444 lib/systemd/user/pizauth-state-gpg-passphrase.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-gpg-passphrase.service\n\tinstall -d ${DESTDIR}${EXAMPLESDIR}/pizauth\n\tinstall -c -m 444 examples/pizauth-state-custom.service ${DESTDIR}${EXAMPLESDIR}/pizauth/pizauth-state-custom.service\n\ntest:\n\tcargo test\n\tcargo test --release\n\ndistrib:\n\ttest \"X`git status --porcelain`\" = \"X\"\n\t@read v?'pizauth version: ' \\\n\t  && mkdir pizauth-$$v \\\n\t  && cp -rp Makefile build.rs Cargo.lock Cargo.toml \\\n\t    COPYRIGHT LICENSE-APACHE LICENSE-MIT \\\n\t    CHANGES.md README.md README.systemd.md \\\n\t    pizauth.1 pizauth.conf.5 \\\n\t    examples lib share src \\\n\t      pizauth-$$v \\\n\t  && tar cfz pizauth-$$v.tgz pizauth-$$v \\\n\t  && rm -rf pizauth-$$v\n"
  },
  {
    "path": "README.md",
    "content": "# pizauth: an OAuth2 token requester daemon\n\npizauth is a simple program for requesting, showing, and refreshing OAuth2\naccess tokens. pizauth is formed of two components: a persistent server which\ninteracts with the user to request tokens, and refreshes them as necessary; and\na command-line interface which can be used by programs such as\n[fdm](https://github.com/nicm/fdm) and [msmtp](https://marlam.de/msmtp/) to\nauthenticate with OAuth2.\n\n## Quick setup\n\npizauth's configuration file is `~/.config/pizauth.conf`. You need to specify\nat least one `account`, which tells pizauth how to authenticate against a\nparticular OAuth2 setup. Most users will also want to receive asynchronous\nnotifications of authorisation requests and errors, which requires setting\n`auth_notify_cmd` and `error_notify_cmd`.\nSee [the bundled example configuration](examples/pizauth.conf) for more details.\n\n\n### Account setup\n\nAt a minimum you need to find out from your provider:\n\n  * The authorisation URI.\n  * The token URI.\n  * Your \"Client ID\" (and in many cases also your \"Client secret\"), which\n    identify your software.\n  * (In some cases) The scope(s) which your OAuth2 access token will give you\n    access to. For pizauth to be able to refresh tokens, you may need to add an\n    explicit `offline_access` scope.\n  * (In some cases) The redirect URI (you must copy this *exactly*, including\n    trailing slash `/` characters). The default value of `http://localhost/`\n    suffices in most instances.\n\nSome providers allow you to create Client IDs and Client Secrets at will (e.g.\n[Google](https://console.developers.google.com/projectselector/apis/credentials)).\nSome providers sometimes allow you to create Client IDs and Client Secrets\n(e.g. [Microsoft\nAzure](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app)\nbut allow organisations to turn off this functionality.\n\nFor example, to create an account called `officesmtp` which obtains OAuth2\ntokens which allow you to read email via IMAP and send email via Office365's\nservers:\n\n```\naccount \"officesmtp\" {\n    auth_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\";\n    token_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/token\";\n    client_id = \"...\"; // Fill in with your Client ID\n    client_secret = \"...\"; // Fill in with your Client secret\n    scopes = [\n      \"https://outlook.office365.com/IMAP.AccessAsUser.All\",\n      \"https://outlook.office365.com/SMTP.Send\",\n      \"offline_access\"\n    ];\n    // You don't have to specify login_hint, but it does make authentication a\n    // little easier.\n    auth_uri_fields = { \"login_hint\": \"email@example.com\" };\n}\n```\n\n### Notifications\n\nAs standard, pizauth displays authorisation URLs and errors on stderr. If you\nwant to use pizauth in the background, it is easy to miss such output.\nFortunately, pizauth can run arbitrary commands to alert you that you need to\nauthorise a new token, in essence giving you the ability to asynchronously\ndisplay notifications. There are two main settings:\n\n  * `auth_notify_cmd` notifies users that an account needs authenticating. The\n    command is run with two environment variables set:\n      * `PIZAUTH_ACCOUNT` is set to the account name to be authorised.\n      * `PIZAUTH_URL` is set to the authorisation URL.\n  * `error_notify_cmd` notifies users of errors.  The command is run with two\n    environment variables set:\n      * `PIZAUTH_ACCOUNT` is set to the account name to be authorised.\n      * `PIZAUTH_MSG` is set to the error message.\n\nFor example to use pizauth with `notify-send`:\n\n```\nauth_notify_cmd = \"if [[ \\\"$(notify-send -A \\\"Open $PIZAUTH_ACCOUNT\\\" -t 30000 'pizauth authorisation')\\\" == \\\"0\\\" ]]; then open \\\"$PIZAUTH_URL\\\"; fi\";\nerror_notify_cmd = \"notify-send -t 90000 \\\"pizauth error for $PIZAUTH_ACCOUNT\\\" \\\"$PIZAUTH_MSG\\\"\";\n```\n\nIn this example, `notify-send` is used to open a notification with a \"Open\n&lt;account&gt;\" button; if that button is clicked, then the authorisation URL\nis opened in the user's default web browser.\n\n\n### Running pizauth\n\nYou need to start the pizauth server (alternatively, start `pizauth.service`,\nsee [systemd-unit](README.systemd.md#systemd-unit)):\n\n```sh\n$ pizauth server\n```\n\nand configure software to request OAuth2 tokens with `pizauth show officesmtp`.\nThe first time that `pizauth show officesmtp` is executed, it will print an\nerror to stderr that includes an authorisation URL (and, if `auth_notify_cmd`\nis set, it will also execute that command):\n\n```\n$ pizauth show officesmtp\nERROR - Token unavailable until authorised with URL https://login.microsoftonline.com/common/oauth2/v2.0/authorize?access_type=offline&code_challenge=xpVa0mDzvR1Ozw5_cWN43DsO-k5_blQNHIzynyPfD3c&code_challenge_method=S256&scope=https%3A%2F%2Foutlook.office365.com%2FIMAP.AccessAsUser.All+https%3A%2F%2Foutlook.office365.com%2FSMTP.Send+offline_access&client_id=<your Client ID>&redirect_uri=http%3A%2F%2Flocalhost%3A14204%2F&response_type=code&state=%25E6%25A0%25EF%2503h6%25BCK&client_secret=<your Client Secret>&login_hint=email@example.com\n```\n\nThe user then needs to open that URL in the browser of their choice and\ncomplete authentication. Once complete, pizauth will be notified, and shortly\nafterwards `pizauth show officesmtp` will start showing a token on stdout:\n\n```\n$ pizauth show officesmtp\nDIASSPt7jlcBPTWUUCtXMWtj9TlPC6U3P3aV6C9NYrQyrhZ9L2LhyJKgl5MP7YV4\n```\n\nNote that:\n\n  1. `pizauth show` does not block: if a token is not available it will fail;\n     once a token is available it will succeed.\n  2. `pizauth show` can print OAuth2 tokens which are no longer valid. By\n     default, pizauth will continually refresh your token, but it may\n     eventually become invalid. There will be a delay between the token\n     becoming invalid and pizauth realising that has happened and notifying you\n     to request a new token.\n\n\n## Command-line interface\n\npizauth's usage is:\n\n```\npizauth dump\npizauth refresh [-u] <account>\npizauth reload\npizauth restore\npizauth server [-c <config-path>] [-d]\npizauth show [-u] <account>\npizauth shutdown\n```\n\nWhere:\n\n* `pizauth refresh` tries to obtain a new access token for an account. If an\n  access token already exists, a refresh is tried; if an access token doesn't\n  exist, a new request is made.\n* `pizauth reload` causes the server to reload its configuration (this is\n  a safe equivalent of the traditional `SIGHUP` mechanism).\n* `pizauth server` starts a new instance of the server.\n* `pizauth show` displays an access token, if one exists, for `account`. If an\n  access token does not exist, a new request is initiated.\n* `pizauth shutdown` asks the server to shut itself down.\n\n`pizauth dump` and `pizauth restore` are explained in the\n[Persistence](#persistence) section below.\n\n\n## Example integrations\n\nOnce you have set up pizauth, you will then need to set up the software which\nneeds access tokens. This section contains example configuration snippets to\nhelp you get up and running.\n\nIn these examples, text in chevrons (like `<this>`) needs to be edited to match\nyour individual setup. The examples assume that `pizauth` is in your `$PATH`:\nif it is not, you will need to substitute an absolute path to `pizauth` in\nthese snippets.\n\n### msmtp\n\nIn your configuration file (typically `~/.config/msmtp/config`):\n\n```\naccount <account-name>\nauth xoauth2\nhost <smtp-server>\nprotocol smtp\nfrom <email-address>\nuser <username>\npasswordeval pizauth show <pizauth-account-name>\n```\n\n### mbsync\n\nEnsure you have the xoauth2 plugin for cyrus-sasl installed, and then use\nsomething like this for the IMAP account in `~/.mbsyncrc`:\n\n```\nIMAPAccount <account-name>\nHost <imap-server>\nUser <username>\nPassCmd \"pizauth show <pizauth-account-name>\"\nAuthMechs XOAUTH2\n```\n\n\n## Example account settings\n\nEach provider you wish to authenticate with will have its own settings it\nrequires of you. These can be difficult to find, so examples are provided in\nthis section. Caveat emptor: these settings will not work in all situations,\nand providers have historically required users to intermittently change their\nsettings.\n\n### Microsoft / Exchange\n\nYou may need to create your own client ID and secret by registering with\nMicrosoft's [identity\nplatform](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app).\n\n```\naccount \"<your-account-name>\" {\n    auth_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\";\n    auth_uri_fields = { \"login_hint\": \"<your-email-address>\" };\n    token_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/token\";\n    client_id = \"<your-client-id>\";\n    client_secret = \"<your-client-secret>\";\n    scopes = [\n      \"https://outlook.office365.com/IMAP.AccessAsUser.All\",\n      \"https://outlook.office365.com/POP.AccessAsUser.All\",\n      \"https://outlook.office365.com/SMTP.Send\",\n      \"offline_access\"\n    ];\n}\n```\n\n### Gmail\n\nYou may need to create your own client ID and secret via the [credentials\ntab](https://console.cloud.google.com/apis/credentials/oauthclient/) of the\nGoogle Cloud Console.\n\n```\naccount \"<your-account-name>\" {\n    auth_uri = \"https://accounts.google.com/o/oauth2/auth\";\n    auth_uri_fields = {\"login_hint\": \"<your-email-address>\"};\n    token_uri = \"https://oauth2.googleapis.com/token\";\n    client_id = \"<your-client-id>\";\n    client_secret = \"<your-client-secret>\";\n    scopes = [\"https://mail.google.com/\"];\n}\n```\n\n### Miele\n\nYou may need to create your own client ID and secret via the [get involved\ntab](https://www.miele.com/f/com/en/register_api.aspx) of the Miele Developer\nsite.\n\nNo scopes are needed.\n\n```\naccount \"<your-account-name>\" {\n    auth_uri = \"https://api.mcs3.miele.com/thirdparty/login/\";\n    token_uri = \"https://api.mcs3.miele.com/thirdparty/token/\";\n    client_id = \"<your-client-id>\";\n    client_secret = \"<your-client-secret>\";\n}\n```\n\n\n## pizauth on a remote machine\n\nYou can run pizauth on a remote machine and have your local machine\nauthenticate that remote existence with `ssh -L`. pizauth contains a small HTTP\nserver used to receive authentication requests. By default the HTTP server\nlistens on a random port, but it is easiest in this scenario to fix a port with\nthe global `http_listen` option:\n\n```\nhttp_listen = \"127.0.0.1:<port-number>\";\naccount \"...\" { ... }\n```\n\nThen on your local machine (using the same `<port-number>` as above run `ssh`:\n\n```\nssh -L 127.0.0.1:<port-number>:127.0.0.1:<port-number> <remote>\n```\n\nThen on the remote machine start `pizauth server` and then `pizauth show\n<account-name>`. Copy the authentication URL into a browser on your local\nmachine and continue as normal. When you see the \"pizauth processing\nauthentication: you can safely close this page.\" message you can close the\n`ssh` tunnel. If the account later needs reauthenticating (e.g. because the\nrefresh token has become invalid), simply reopen the `ssh` tunnel,\nreauthenticate, and close the `ssh` tunnel.\n\n\n## Persistence\n\nBy design, pizauth stores tokens state only in memory, and never to disk: users\nnever have to worry that unencrypted secrets may be accessible on disk.\nHowever, if you use pizauth on a machine where pizauth is regularly restarted\n(e.g. because the machine is regularly rebooted), reauthenticating each time\ncan be frustrating.\n\n`pizauth dump` (which writes pizauth's internal token state to `stdout`) and\n`pizauth restore` (which restores previously dumped token state read from\n`stdin`) allow you to persist state, but since they contain secrets they\ninevitably increase your security responsibilities. Although the output from\n`pizauth dump` may look like it is encrypted, it is trivial for an attacker to\nrecover secrets from it: it is strongly recommended that you immediately\nencrypt the output from `pizauth dump` to avoid possible security issues.\n\nThe most common way to call `pizauth dump` is via the `token_event_cmd`\nconfiguration setting. `token_event_cmd` is called each time an account's\ntokens change state (e.g. new tokens, refreshed tokens, etc). You can use this\nto run an arbitrary shell command such as `pizauth dump`:\n\n```\ntoken_event_cmd = \"pizauth dump | age --encrypt --output pizauth.age -R age_public_key\";\n```\n\nIn this example, output from `pizauth dump` is immediately encrypted using\n[age](https://age-encryption.org/). In your machine's startup process you can\nthen call `pizauth restore` to restore the most recent dump e.g.:\n\n```\nage --decrypt -i age_private_key -o - pizauth.age | pizauth restore\n```\n\nNote that `pizauth restore` does not change the running pizauth's\nconfiguration. Any changes in security relevant configuration between the\ndumping and restoring pizauth instances cause those parts of the dump to be\nsilently ignored.\n\n\n## Alternatives\n\npizauth will not be perfect for everyone. You may also wish to consider these\nprograms as alternatives:\n\n* [Email OAuth 2.0 Proxy](https://github.com/simonrob/email-oauth2-proxy)\n* [mailctl](https://github.com/pdobsan/mailctl)\n* [mutt_oauth2.py](https://gitlab.com/muttmua/mutt/-/blob/master/contrib/mutt_oauth2.py)\n* [oauth-helper-office-365](https://github.com/ahrex/oauth-helper-office-365)\n"
  },
  {
    "path": "README.systemd.md",
    "content": "# Systemd unit\n\nPizauth comes with a systemd unit. In order for it to communicate properly with\n`systemd`, your `startup_cmd` in `pizauth.conf` must at some point run\n`systemd-notify --ready --pid=parent` -- this will tell `systemd` that `pizauth`\nhas started up.\n\nTo start pizauth:\n\n```sh\n$ systemctl --user start pizauth.service\n```\n\nIf you want `pizauth` to start on login, run\n\n```sh\n$ systemctl --user enable pizauth.service\n```\n\n(pass `--now` to also start `pizauth` with this invocation)\n\nIf you want to save pizauth's dumps encrypted and automatically restore them\nwhen pizauth is started, you need to start/enable one of the\n`pizauth-state-*.service` files provided by pizauth. For example,\n\n```sh\n$ systemctl --user enable pizauth-state-creds.service\n```\n\nSome of these units require further configuration, eg for setting the public key\nand location of the private key to use for encryption. For this purpose,\n\n```sh\n$ systemctl --user edit pizauth-state-$METHOD.service\n```\n\nwill open an editor in which you can configure your local edits to\n`pizauth-state-$METHOD.service`. For example, you can override the default\nlocation of the pizauth dumps (`$XDG_STATE_HOME/pizauth-state-$METHOD.dump`) to\nbe `~/.pizauth.dump` by inserting the following line in the `.conf` file that\n`systemctl` will open:\n\n```ini\nEnvironment=\"PIZAUTH_STATE_FILE=%h/.pizauth.dump\n```\n\nSee `systemd.unit(5)` for supported values of these % \"specifiers\".\n\nThe provided configurations are:\n- `pizauth-state-creds.service`: Uses `systemd-creds` to encrypt the dumps with\n  some combination of your device's TPM2 chip and a secret accessible only to\n  `root`. This means the dumps generally can only be decrypted *on the device\n  that encrypted them*.\n- `pizauth-state-age.service`: Uses `age` to encrypt the dumps.\n  Needs the `Environment=\"PIZAUTH_KEY_ID=\"` line to be set to the public key to\n  encrypt with.\n- `pizauth-state-gpg.service`: Uses `gpg` to encrypt the dumps.\n  Needs the `Environment=\"PIZAUTH_KEY_ID=\"` line to be set to the public key to\n  encrypt with. `gpg-agent` will prompt for the passphrase to unlock the key,\n  which may be undesireable in nongraphical environments.\n- `pizauth-state-gpg-passphrase.service`: Uses `gpg` to encrypt the dumps.\n  Uses `systemd-creds` to encrypt a file containing the passphrase, which is set\n  by default to be `$XDG_CONFIG_HOME/pizauth-state-gpg-passphrase.cred`.\n  Needs the `Environment=\"PIZAUTH_KEY_ID=\"` line to be set to the public key to\n  encrypt with. Also needs the passphrase to be stored encrypted somewhere, see\n  the unit file for details.\n\n  Note: Given the security implications here, this method is likely not much\n  more secure than just using `pizauth-state-creds.service` directly.\n  This unit is provided mostly to document how one might go about automatically\n  passing key material relatively safely to a unit.\n"
  },
  {
    "path": "build.rs",
    "content": "use cfgrammar::yacc::YaccKind;\nuse lrlex::{CTLexerBuilder, DefaultLexerTypes};\nuse rerun_except::rerun_except;\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n    rerun_except(&[\n        \"CHANGES.md\",\n        \"LICENSE-APACHE\",\n        \"LICENSE-MIT\",\n        \"pizauth.1\",\n        \"pizauth.conf.5\",\n        \"pizauth.conf.example\",\n        \"README.md\",\n    ])?;\n\n    CTLexerBuilder::<DefaultLexerTypes<u8>>::new_with_lexemet()\n        .lrpar_config(|ctp| {\n            ctp.yacckind(YaccKind::Grmtools)\n                .grammar_in_src_dir(\"config.y\")\n                .unwrap()\n        })\n        .lexer_in_src_dir(\"config.l\")?\n        .build()?;\n    Ok(())\n}\n"
  },
  {
    "path": "examples/pizauth-state-custom.service",
    "content": "# In case the supplied pizauth-state-*.service files don't suit your needs,\n# this is the template for a new pizauth-state-*.service file.\n# See systemd.service(5), systemd.unit(5), systemd.exec(5) for more details.\n#\n# We pull out the dump/restore feature as its own unit since under the systemd\n# semantics, it makes more sense -- as indicated by the fact that were we to\n# try to configure this directly in the pizauth unit, we'd need to prepend to\n# ExecStop. Moreover, pizauth *works* without dump/restore -- this is an\n# additional feature on top of it.\n#\n# Hence, we have a separate dump/restore unit that gets started after pizauth\n# and torn down before it, and which is responsible for managing the state file\n# upon these events.\n\n[Unit]\nDescription=Custom pizauth dump/restore backend\n# Makes the start event for this unit propagate to pizauth,\n# and makes the stop/abort events for pizauth propagate to this unit\nBindsTo=pizauth.service\n# Orders this unit to run its start commands after pizauth, and run its stop\n# commands before pizauth\nAfter=pizauth.service\n# Makes the config-reload event for pizauth propagate to this unit\nReloadPropagatedFrom=pizauth.service\n\n[Service]\nType=simple\nEnvironment=\"PIZAUTH_STATE_FILE=%S/%N.dump\"\n# replace io by whatever program you have to read/write to the state file\n# (could be encryption software, could be sending/retrieving to a server, etc)\nExecStart=-sh -c 'io --read \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecReload=-sh -c 'io --read \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecStop=-sh -c 'pizauth dump | io --write \"$PIZAUTH_STATE_FILE\"'\n\n[Install]\n# Makes systemctl --user enable pizauth-state-custom.service cause that the\n# start event for pizauth will propagate to this unit\nWantedBy=pizauth.service\n"
  },
  {
    "path": "examples/pizauth.conf",
    "content": "account \"officesmtp\" {\n    auth_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\";\n    token_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/token\";\n    client_id = \"...\"; // Fill in with your Client ID\n    client_secret = \"...\"; // Fill in with your Client secret\n    scopes = [\n      \"https://outlook.office365.com/IMAP.AccessAsUser.All\",\n      \"https://outlook.office365.com/SMTP.Send\",\n      \"offline_access\"\n    ];\n    // You don't have to specify login_hint, but it does make\n    // authentication a little easier.\n    auth_uri_fields = { \"login_hint\": \"email@example.com\" };\n}\n"
  },
  {
    "path": "examples/systemd/pizauth.conf",
    "content": "// If using systemd, comment out the following line\n// startup_cmd=\"systemd-notify --ready --pid=parent\";\n// If using the pizauth-state-*.service units to save the state,\n// the following may be useful -- it will trigger a save/restore of the state\n// upon each token state change (set METHOD to whatever storage method you're\n// using)\n// token_event_cmd=\"systemctl --user restart pizauth-state-METHOD.service\";\n"
  },
  {
    "path": "lib/systemd/user/pizauth-state-age.service",
    "content": "[Unit]\nDescription=pizauth dump/restore backend (encryption: age)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPropagatedFrom=pizauth.service\n\n[Service]\nType=simple\nRemainAfterExit=yes\n# This unit needs to be configured before it's usable!\n# To use this unit, use systemctl --user edit pizauth-state-age.service to\n# create a drop-in configuration file. In it, set\n# Environment=\"PIZAUTH_KEY_ID=public key you want to encrypt with\"\n# Environment=\"PIZAUTH_KEY_FILE=path to file\nEnvironment=\"PIZAUTH_KEY_ID=\"\nEnvironment=\"PIZAUTH_KEY_FILE=\"\nEnvironment=\"PIZAUTH_STATE_FILE=%S/%N.dump\"\nExecStart=-sh -c 'age --decrypt --identity \"$PIZAUTH_KEY_FILE\" -o - \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecReload=-sh -c 'age --decrypt --identity \"$PIZAUTH_KEY_FILE\" -o - \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecStop=-sh -c 'pizauth dump | age --encrypt --recipient \"$PIZAUTH_KEY_ID\" -o \"$PIZAUTH_STATE_FILE\"'\n\n[Install]\nWantedBy=pizauth.service\n"
  },
  {
    "path": "lib/systemd/user/pizauth-state-creds.service",
    "content": "[Unit]\nDescription=pizauth dump/restore backend (encryption: systemd-creds)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPropagatedFrom=pizauth.service\n\n[Service]\nType=simple\nRemainAfterExit=yes\nEnvironment=\"PIZAUTH_STATE_FILE=%S/%N.dump\"\nExecStart=-sh -c 'systemd-creds --user decrypt $PIZAUTH_STATE_FILE | pizauth restore'\nExecReload=-sh -c 'systemd-creds --user decrypt $PIZAUTH_STATE_FILE | pizauth restore'\nExecStop=-sh -c 'pizauth dump | systemd-creds --user encrypt - $PIZAUTH_STATE_FILE'\n\n[Install]\nWantedBy=pizauth.service\n"
  },
  {
    "path": "lib/systemd/user/pizauth-state-gpg-passphrase.service",
    "content": "[Unit]\nDescription=pizauth dump/restore backend (encryption: gpg, passphrase in systemd-creds)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPropagatedFrom=pizauth.service\nRequires=gpg-agent.socket\n\n[Service]\n# Since we're using credentials, we can't use Type=simple\nType=exec\nRemainAfterExit=yes\n# This unit needs to be configured before it's usable!\n# To use this unit, use systemctl --user edit pizauth-state-gpg.service to\n# create a drop-in configuration file. In it, set\n# Environment=\"PIZAUTH_KEY_ID=public key you want to encrypt with\"\n# and then either\n# LoadCredentialEncrypted=pizauth-gpg-passphrase:CREDENTIALFILE\n# or\n# SetCredentialEncrypted=pizauth-gpg-passphrase: \\\n#         ..................................................................... \\\n#         ...\n#\n# In either case, you will need to store the passphrase for the GPG key\n# encrypted. If you plan on storing the credential at CREDENTIALFILE, run\n# systemd-ask-password \\\n#   | systemd-creds encrypt --name pizauth-gpg-passphrase - CREDENTIALFILE\n# If you want to store the credential in the drop-in configuration, run\n# systemd-ask-password \\\n#   | systemd-creds encrypt --name pizauth-gpg-passphrase -p - -\n# This will print the SetCredentialEncrypted config you'll need to paste in the\n# drop-in configuration\nEnvironment=\"PIZAUTH_KEY_ID=\"\nEnvironment=\"PIZAUTH_STATE_FILE=%S/%N.dump\"\nExecStart=-sh -c ' gpg --passphrase-file %d/pizauth-gpg-passphrase --pinentry-mode loopback --batch --decrypt \"$PIZAUTH_STATE_FILE\" \\\n    | pizauth restore'\nExecReload=-sh -c ' gpg --passphrase-file %d/pizauth-gpg-passphrase --pinentry-mode loopback --batch --decrypt \"$PIZAUTH_STATE_FILE\" \\\n    | pizauth restore'\nExecStop=-sh -c 'pizauth dump | gpg --batch --yes --encrypt --recipient $PIZAUTH_KEY_ID -o \"$PIZAUTH_STATE_FILE\"'\n\n[Install]\nWantedBy=pizauth.service\n"
  },
  {
    "path": "lib/systemd/user/pizauth-state-gpg.service",
    "content": "[Unit]\nDescription=pizauth dump/restore backend (encryption: gpg)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPropagatedFrom=pizauth.service\nRequires=gpg-agent.socket\n\n[Service]\nType=simple\nRemainAfterExit=yes\n# This unit needs to be configured before it's usable!\n# To use this unit, use systemctl --user edit pizauth-state-age.service to\n# create a drop-in configuration file. In it, set\n# Environment=\"PIZAUTH_KEY_ID=public key you want to encrypt with\"\nEnvironment=\"PIZAUTH_KEY_ID=\"\nEnvironment=\"PIZAUTH_STATE_FILE=%S/%N.dump\"\nExecStart=-sh -c 'gpg --batch --decrypt \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecReload=-sh -c 'gpg --batch --decrypt \"$PIZAUTH_STATE_FILE\" | pizauth restore'\nExecStop=-sh -c 'pizauth dump | gpg --batch --yes --encrypt --recipient $PIZAUTH_KEY_ID -o \"$PIZAUTH_STATE_FILE\"'\n\n[Install]\nWantedBy=pizauth.service\n"
  },
  {
    "path": "lib/systemd/user/pizauth.service",
    "content": "[Unit]\nDescription=Pizauth OAuth2 token manager\nDocumentation=man:pizauth(1) man:pizauth.conf(5)\nDocumentation=https://github.com/ltratt/pizauth/blob/master/README.md\nDocumentation=https://github.com/ltratt/pizauth/blob/master/README.systemd.md\n\n[Service]\nType=notify\n# Allow all processes in the cgroup to set the service status, needed to be able\n# to set status via eg startup_cmd, token_event_cmd, etc\nNotifyAccess=all\nExecStart=/usr/bin/pizauth server -vvvv -d\nExecReload=/usr/bin/pizauth reload\nExecStop=/usr/bin/pizauth shutdown\n\n[Install]\nWantedBy=default.target\n"
  },
  {
    "path": "pizauth.1",
    "content": ".Dd $Mdocdate: September 13 2022 $\n.Dt PIZAUTH 1\n.Os\n.Sh NAME\n.Nm pizauth\n.Nd OAuth2 authentication daemon\n.Sh SYNOPSIS\n.Nm pizauth\n.Sy Em command\n.Sh DESCRIPTION\n.Nm\nrequests, shows, and refreshes OAuth2 tokens.\nIt is formed of two\ncomponents: a persistent \"server\" which interacts with the user to obtain\ntokens, and refreshes them as necessary; and a command-line interface which can\nbe used by other programs to show the OAuth2 token for a current account.\n.Pp\nThe top-level commands are:\n.Bl -tag -width Ds\n.It Sy dump\nWrites the current\n.Nm\nstate to stdout: this can later be fed back into\n.Nm\nwith\n.Sy restore .\nThe dump format is stable within a pizauth major release (but not\nacross major releases) and stable across platforms, though it includes\ntimestamps that may be affected by clock drift on either the machine performing\n.Sy dump\nor\n.Sy restore .\nClock drift does not not affect security, though it may cause dumped access\ntokens to be refreshed unduly early or late upon a\n.Sy restore .\nRefreshed access tokens will then be refreshed at the expected intervals.\n.Pp\nNote that while the\n.Sy dump\noutput may look like it is encrypted, it is trivial for an attacker to recover\naccess and refresh tokens from it: it is strongly recommended that you use\nexternal encryption on the output so that your data cannot be compromised.\n.It Sy info Oo Fl j Oc\nWrites output about\n.Nm\nto stdout including: the cache directory path; the config file path; and\n.Nm\nversion.\nDefaults to human-readable output in an unspecified format that may change\nfreely between\n.Nm\nversions.\n.Pp\n.Fl j\nspecifies JSON output.\nThe\n.Qq info_format_version\nfield is an integer value specifying the version of the JSON output: if\nincompatible changes are made, this integer will be monotonically increased.\n.It Sy refresh Oo Fl u Oc Ar account\nRequest a refresh of the access token for\n.Em account .\nExits with 0 upon success.\nIf there is not currently a valid access or refresh token,\nreports an error to stderr, initiates a new token request, and exits with 1.\nUnless\n.Fl u\nis specified, the error will include an authorization URL.\nNote that this command does not block and will not start a new refresh if one\nis ongoing.\n.It Sy reload\nReload the server's configuration.\nExits with 0 upon success or 1 if there is a problem in the configuration.\n.It Sy restore\nReads previously dumped\n.Nm\nstate from stdin and updates those parts of the current state it determines\nto be less useful than the dumped state.\nThis does not change the running instance's configuration: any changes in\nsecurity relevant configuration between the dumping and restoring\n.Nm\ninstances causes those parts of the dump to be silently ignored.\nSee\n.Sy dump\nfor information about the dump format, timestamp warnings, and encryption\nsuggestions.\n.It Sy revoke Ar account\nRemoves any token, and cancels any ongoing authentication, for\n.Em account .\nNote that OAuth2 provides no standard way of remotely revoking a token:\n.Sy revoke\nthus only affects the local\n.Nm\ninstance.\nExits with 0 upon success.\n.It Sy server Oo Fl c Ar config-file Oc Oo Fl dv Oc\nStart the server.\nIf not specified with\n.Fl c ,\n.Nm\nchecks for the configuration file (in order) at:\n.Pa $XDG_CONFIG_HOME/pizauth.conf ,\n.Pa $HOME/.config/pizauth.conf .\nThe server will daemonise itself unless\n.Fl d\nis specified.\nExits with 0 if the server started successfully or 1 otherwise.\n.Fl v\nenables more verbose logging.\n.Fl v\ncan be used up to 4 times, with each repetition increasing the quantity\nof logging.\n.It Sy show Oo Fl u Oc Ar account\nIf there is an access token for\n.Em account ,\nprint that access token to stdout and exit with 0.\nIf there is not currently a valid access token, prints an error to stderr\nand exits with 1.\nIf refreshing might obtain a valid access token, refreshing is initiated\nin the background.\nOtherwise (unless\n.Fl u\nis specified), the error will include an authorization URL.\nNote that this command does not block: commands must expect that they might\nencounter an error when showing an access token.\n.It Sy shutdown\nShut the server down.\nNote that shutdown occurs asynchronously: the server may still be alive for a\nperiod of time after this command returns.\n.It Sy status\nWrites output about the current accounts and whether they have access tokens to\nstdout. The format is human-readable and in an unspecified format that may\nchange freely between\n.Nm\nversions.\n.El\n.Sh SEE ALSO\n.Xr pizauth.conf 5\n.Pp\n.Lk https://tratt.net/laurie/src/pizauth/\n.Sh AUTHORS\n.An -nosplit\n.Nm\nwas written by\n.An Laurence Tratt Lk https://tratt.net/laurie/\n"
  },
  {
    "path": "pizauth.conf.5",
    "content": ".Dd $Mdocdate: September 13 2022 $\n.Dt PIZAUTH.CONF 5\n.Os\n.Sh NAME\n.Nm pizauth.conf\n.Nd pizauth configuration file\n.Sh DESCRIPTION\n.Nm\nis the configuration file for\n.Xr pizauth 1 .\n.Pp\nThe top-level options are:\n.Bl -tag -width Ds\n.It Sy auth_notify_cmd = Qo Em shell-cmd Qc ;\nspecifies a shell command to be run via\n.Ql $SHELL -c\nwhen an account needs to be authenticated.\nTwo special environment variables are set:\n.Em $PIZAUTH_ACCOUNT\nis set to the account name;\n.Em $PIZAUTH_URL\nis set to the URL required to authorise the account.\nNote that\n.Sy auth_event_cmd\nis subject to a 10 second timeout.\nOptional.\nOptional.\n.It Sy auth_notify_interval = Em time ;\nspecifies the gap between reminders to the user of authentication requests.\nDefaults to 15 minutes if not specified.\n.It Sy error_notify_cmd = Qo Em shell-cmd Qc ;\nspecifies a shell command to be run via\n.Ql $SHELL -c\nwhen an error has occurred when authenticating an account.\nTwo special environment variables are set:\n.Em $PIZAUTH_ACCOUNT\nis set to the account name;\n.Em $PIZAUTH_MSG\nis set to the error message.\nDefaults to logging via\n.Xr syslog 3\nif not specified.\n.It Sy http_listen = Em none | Qo Em bind-name Qc ;\nspecifies the address for the\n.Xr pizauth 1\nHTTP server to listen on.\nIf\n.Em none\nis specified, the HTTP server is turned off entirely.\nNote that at least one of the HTTP and HTTPS servers must be turned on.\nDefaults to\n.Qq 127.0.0.1:0 .\n.It Sy https_listen = Em none | Qo Em bind-name Qc ;\nspecifies the address for the\n.Xr pizauth 1\nHTTPS server to listen on.\nIf\n.Em none\nis specified, the HTTPS server is turned off entirely.\nNote that at least one of the HTTP and HTTPS servers must be turned on.\nDefaults to\n.Qq 127.0.0.1:0 .\n.It Sy refresh_at_least = Em time ;\nspecifies the maximum period of time before an access token will be forcibly\nrefreshed.\nDefaults to 90 minutes if not specified.\n.It Sy refresh_before_expiry = Em time ;\nspecifies how far in advance an access token should be refreshed before it\nexpires.\nDefaults to 90 seconds if not specified.\n.It Sy refresh_retry = Em time ;\nspecifies the gap between retrying refreshing after transitory errors\n(e.g. due to network problems).\nDefaults to 40 seconds if not specified.\n.It Sy startup_cmd = Qo Em shell-cmd Qc ;\nspecifies a shell command to be run via\n.Ql $SHELL -c\nafter pizauth's server has completed setup and is ready to accept commands.\nNote that unless\n.Fl d\nis set,\n.Sy startup_cmd\nwill be run after\n.Xr pizauth 1\nhas daemonised.\nThe command will thus be run with stdin and stdin closed.\n.It Sy token_event_cmd = Qo Em shell-cmd Qc ;\nspecifies a shell command to be run via\n.Ql $SHELL -c\nwhen an account's access token changes state.\nTwo special environment variables are set:\n.Em $PIZAUTH_ACCOUNT\nis set to the account name;\n.Em $PIZAUTH_EVENT\nis set to the event type.\nThe event types are:\n.Em token_invalidated\nif a previously valid access token is invalidated;\n.Em token_new\nif a new access token is obtained;\n.Em token_refreshed\nif an access token is refreshed;\n.Em token_revoked\nif the user has requested that any token, or ongoing authentication for,\nan account should be removed or cancelled.\nToken events are queued and processed one-by-one in the order they were\nreceived: at most one instance of\n.Sy token_event_cmd\nwill be executed at any point in time; and there is no guarantee\nthat an event reflects the current state of an account's access token,\nsince further events may be stored in the queue.\nNote that\n.Sy token_event_cmd\nis subject to a 10 second timeout.\nOptional.\n.It Sy transient_error_if_cmd = Qo Em shell-cmd Qc ;\nspecifies a shell command to be run when pizauth repeatedly encounters\nerrors when trying to refresh a token.\nOne special environment variable is set:\n.Em $PIZAUTH_ACCOUNT\nis set to the account name.\nIf\n.Em shell-cmd\nreturns a zero exit code, the transient errors are ignored.\nIf\n.Em shell-cmd\nreturns a non-zero exit code, or exceeds a 3 minute timeout, pizauth treats\nthe errors as permanent: the access token is invalidated (forcing the user\nto later reauthenicate).\nDefaults to ignoring non-fatal errors if not specified.\n.El\n.Pp\nAn\n.Sq account\nblock supports the following options:\n.Bl -tag -width Ds\n.It Sy auth_uri = Qo Em URI Qc ;\nwhere\n.Em URI\nis a URI specifying the OAuth2 server's authentication URI.\nMandatory.\n.It Sy auth_uri_fields = { Qo Em Key 1 Qc : Qo Em Val 1 Qc , ..., Qo Em Key n Qc : Qo Val n Qc } ;\nspecifies zero or more query fields to be passed to\n.Sy auth_uri\nafter any fields that\n.Nm\nmay have added itself.\nKeys (and their values) are added to\n.Sy auth_uri\nin the order they appear in\n.Sy auth_uri_fields ,\neach separated by\n.Qq & .\nThe same key may be specified multiple times.\nOptional.\n.It Sy client_id = Qo Em ID Qc ;\nspecifies the OAuth2 client ID (i.e. the identifier of the client software).\nMandatory.\n.It Sy client_secret = Qo Em Secret Qc ;\nspecifies the OAuth2 client secret (similar to the\n.Em client_id ) .\nOptional.\n.It Sy login_hint = Qo Em Hint Qc ;\nis used by the authentication server to help the user understand which account\nthey are authenticating.\nTypically a username or email address.\nOptional.\n.Em Deprecated :\nuse\n.Ql auth_uri_fields = { Qo login_hint Qc : Qo Hint Qc }\ninstead.\n.It Sy redirect_uri = Qo Em URI Qc ;\nwhere\n.Em URI\nis a URI specifying the OAuth2 server's redirection URI.\nDefaults to\n.Qq http://localhost/\nif not specified.\n.It Sy refresh_at_least = Em time ;\nOverrides the global\n.Sy refresh_at_least\noption for this account.\nFollows the same format as the global option.\n.It Sy refresh_before_expiry = Em time ;\nOverrides the global\n.Sy refresh_before_expiry\noption for this account.\nFollows the same format as the global option.\n.It Sy refresh_retry = Em time ;\nOverrides the global\n.Sy refresh_retry\noption for this account.\nFollows the same format as the global option.\n.It Sy scopes = [ Qo Em Scope 1 Qc , ..., Qo Em Scope n Qc ] ;\nspecifies zero or more OAuth2 scopes (roughly speaking,\n.Qq permissions )\nthat access tokens will give you permission to utilise.\nOptional.\n.It Sy token_uri = Qo Em URI Qc ;\nis a URI specifying the OAuth2 server's token URI.\nMandatory.\n.El\n.Pp\nTimes can be specified as\n.Em int [smhd]\nwhere the suffixes mean (in order): seconds, minutes, hours, days.\nFor example,\n.Em 90s\nmeans 90 seconds and\n.Em 5m\nmeans 5 minutes.\n.Sh EXAMPLES\nAn example\n.Nm\nfile for accessing IMAP and SMTP services in Office365\nis as follows:\n.Bd -literal -offset 4n\naccount \"officesmtp\" {\n    auth_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\";\n    token_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/token\";\n    client_id = \"...\"; // Fill in with your Client ID\n    client_secret = \"...\"; // Fill in with your Client secret\n    scopes = [\n      \"https://outlook.office365.com/IMAP.AccessAsUser.All\",\n      \"https://outlook.office365.com/SMTP.Send\",\n      \"offline_access\"\n    ];\n    // You don't have to specify login_hint, but it does make\n    // authentication a little easier.\n    auth_uri_fields = { \"login_hint\": \"email@example.com\" };\n}\n.Ed\n.Pp\nNote that Office365 requires the non-standard\n.Qq offline_access\nscope to be specified in order for\n.Xr pizauth 1\nto be able to operate successfully.\n.Sh SEE ALSO\n.Xr pizauth 1\n.Pp\n.Lk https://tratt.net/laurie/src/pizauth/\n.Sh AUTHORS\n.An -nosplit\n.Xr pizauth 1\nwas written by\n.An Laurence Tratt Lk https://tratt.net/laurie/\n"
  },
  {
    "path": "share/bash/completion.bash",
    "content": "#!/bin/bash\n_server() {\n    local cur prev\n\n    prev=${COMP_WORDS[COMP_CWORD - 1]}\n    cur=${COMP_WORDS[COMP_CWORD]}\n    case \"$prev\" in\n        -c) _filedir;;\n        *) mapfile -t COMPREPLY < \\\n            <(compgen -W '-c -d -v -vv -vvv -vvvv' -- \"$cur\");;\n    esac\n}\n_accounts(){\n    local config\n\n    config=\"$(pizauth info | awk -F' *: *' '$1 ~ /config file/ { print $2 }')\"\n    sed -n '/^account/{s/^account \\(.*\\) {/\\1/;p}' \"$config\"\n}\n_pizauth()\n{\n    local cur prev sub\n    local cmds=()\n    cmds+=(dump restore reload shutdown status)\n    cmds+=(info server)\n    cmds+=(refresh revoke show)\n\n    cur=${COMP_WORDS[COMP_CWORD]}\n    prev=${COMP_WORDS[COMP_CWORD - 1]}\n    sub=${COMP_WORDS[1]}\n\n    if [ \"$sub\" == server ] && [ \"$COMP_CWORD\" -gt 1 ]; then _server; return; fi\n\n    case ${COMP_CWORD} in\n        1)  mapfile -t COMPREPLY < <(compgen -W \"${cmds[*]}\" -- \"$cur\");;\n        2)\n            case $sub in\n                dump|restore|reload|shutdown|status) COMPREPLY=();;\n                info) mapfile -t COMPREPLY < <(compgen -W '-j' -- \"$cur\") ;;\n                refresh|show)\n                    local accounts\n                    mapfile -t accounts < <(_accounts)\n                    accounts+=(-u)\n                    mapfile -t COMPREPLY < \\\n                        <(compgen -W \"${accounts[*]}\" -- \"$cur\")\n                    ;;\n                revoke)\n                    local accounts\n                    mapfile -t accounts < <(_accounts)\n                    mapfile -t COMPREPLY < \\\n                        <(compgen -W \"${accounts[*]}\" -- \"$cur\")\n                    ;;\n                *) COMPREPLY=()\n                ;;\n            esac\n            ;;\n        3)\n            case $sub in\n                refresh|show)\n                    case $prev in\n                        -u)\n                            local accounts\n                            mapfile -t accounts < <(_accounts)\n                            mapfile -t COMPREPLY < \\\n                                <(compgen -W \"${accounts[*]}\" -- \"$cur\")\n                            ;;\n                        *) COMPREPLY=()\n                    esac\n                    ;;\n            esac\n            ;;\n        *)\n            COMPREPLY=()\n            ;;\n    esac\n}\n\ncomplete -F _pizauth pizauth\n"
  },
  {
    "path": "share/fish/pizauth.fish",
    "content": "#!/usr/bin/fish\n\nfunction __fish_pizauth_accounts --description \"Helper function to parse accounts from config\"\n    set -l config (pizauth info | awk -F' *: *' '$1 ~ /config file/ { print $2 }')\n    sed -n '/^account/{s/^account \\(.*\\) {/\\1/;p}' $config | string unescape\nend\n\nfunction __fish_pizauth_is_main_command --description \"Returns true if we're not in a subcommand\"\n    not __fish_seen_subcommand_from dump restore reload shutdown status info server refresh revoke show\nend\n\n# Don't autocomplete files\ncomplete -c pizauth -f\n\n# pizauth top-level commands\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Writes current pizauth state to stdout\" -a \"dump\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Writes output about pizauth to stdout\" -a \"info\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Request a refresh of the access token for account\" -a \"refresh\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Reloads the server's configuration\" -a \"reload\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Reads previously dumped pizauth state from stdin\" -a \"restore\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Removes token and cancels authorization for account\" -a \"revoke\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Start the server\" -a \"server\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Print access token of account to stdout\" -a \"show\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Shut the server down\" -a \"shutdown\"\ncomplete -c pizauth -n \"__fish_pizauth_is_main_command\" -d \"Writes output about current accounts to stdout\" -a \"status\"\n\n# pizauth info [-j]\ncomplete -c pizauth -n \"__fish_seen_subcommand_from info\" -s j -d \"JSON output\"\n\n# pizauth refresh/show [-u] account\ncomplete -c pizauth -n \"__fish_seen_subcommand_from refresh show\" -s u -d \"Exclude authorization URL\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from refresh show\" -a \"(__fish_pizauth_accounts)\"\n\n# pizauth revoke account\ncomplete -c pizauth -n \"__fish_seen_subcommand_from revoke\" -a \"(__fish_pizauth_accounts)\"\n\n# pizauth server [-c config-file] [-dv]\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -s c -r -F -d \"Config file\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -s d -d \"Do not daemonise\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -o v -d \"Verbose\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -o vv -d \"Verboser\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -o vvv -d \"Verboserer\"\ncomplete -c pizauth -n \"__fish_seen_subcommand_from server\" -o vvvv -d \"Verbosest\"\n"
  },
  {
    "path": "share/zsh/_pizauth",
    "content": "#compdef pizauth\n\n_pizauth_accounts() {\n  local config account\n  local -a accounts\n  accounts=(\"${(@f)$(pizauth status 2>/dev/null | cut -d ':' -f 1)}\")\n  (( ${#accounts} )) || return 1\n  _wanted accounts expl 'account' compadd \"$expl[@]\" -a accounts\n}\n\n_pizauth() {\n  local curcontext=\"$curcontext\" state line ret=1\n  local -a commands\n  typeset -A opt_args\n\n  commands=(\n    'dump:write internal state to stdout for later restore'\n    'info:write config information to stdout'\n    'refresh:request refresh of an access token'\n    'reload:reload server config'\n    'restore:read previously dumped state from stdin'\n    'revoke:remove local access token'\n    'server:start the server'\n    'show:write access token to stdout'\n    'shutdown:shut server down'\n    'status:write accounts state to stdout'\n  )\n\n  _arguments -C \\\n    '1:command:->command' \\\n    '*::argument:->argument' && ret=0\n\n  case \"$state\" in\n    command) _describe -t commands 'pizauth command' commands && ret=0 ;;\n    argument)\n      curcontext=\"${curcontext%:*:*}:pizauth-${words[1]}:\"\n      case $words[1] in\n        dump|reload|restore|shutdown|status) _message 'no more arguments' ;;\n        info) _arguments '-j[write JSON output]' ;;\n        refresh|show)\n          _arguments \\\n            '-u[do not include an authorization URL in errors]' \\\n            '1:account:_pizauth_accounts'\n          ;;\n        revoke) _arguments '1:account:_pizauth_accounts' ;;\n        server)\n          _arguments \\\n            '-c[config file]:config file:_files' \\\n            '-d[do not daemonise]' \\\n            '*-v[verbose: repeat for greater verbosity]'\n          ;;\n        *) _message 'unknown pizauth command' ;;\n      esac\n      ;;\n  esac\n\n  return ret\n}\n\n_pizauth \"$@\"\n"
  },
  {
    "path": "src/compat/daemon.rs",
    "content": "//! Provides daemon(3) on macOS.\n\n// We provide our own wrapper for daemon on macOS because nix does not export one for macOS.  This\n// is *probably* why nix does not support daemon(3) on macOS:\n//\n//  - nix will not compile on macOS, due to errors\n//  - ... nix compiles with #[deny(warnings)], which treats warnings as errors\n//  - libc emits a deprecation warning for daemon(3) on macOS [1]\n//  - ... because daemon(3) has been deprecated in macOS since Mac OS X 10.5\n//  - ... presumably because Apple wants you to use launchd(8) instead [2].\n//  - Therefore, this deprecation warning is treated as an error in nix\n//\n// [1]: https://github.com/rust-lang/libc/blob/96c85c1b913604fb5b1eb8822e344b7c08bcd6b9/src/unix/bsd/apple/mod.rs#L5064-L5067\n// [2]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html\n//\n// This module essentially reimplements nix's daemon wrapper on macOS, but allows deprecation\n// warnings.\n//\n// See: https://github.com/ltratt/pizauth/issues/3\nuse libc::c_int;\n#[allow(deprecated)]\nuse libc::daemon as libc_daemon;\nuse nix::errno::Errno;\n\npub fn daemon(nochdir: bool, noclose: bool) -> nix::Result<()> {\n    #[allow(deprecated)]\n    let res = unsafe { libc_daemon(nochdir as c_int, noclose as c_int) };\n    Errno::result(res).map(drop)\n}\n"
  },
  {
    "path": "src/compat/mod.rs",
    "content": "//! Shims to provide compatibility with different systems.\n\n// nix does not support daemon(3) on macOS, so we have to provide our own implementation:\n#[cfg(target_os = \"macos\")]\nmod daemon;\n#[cfg(target_os = \"macos\")]\npub use daemon::daemon;\n\n// Use nix's daemon(3) wrapper on other platforms:\n#[cfg(not(target_os = \"macos\"))]\npub use nix::unistd::daemon;\n"
  },
  {
    "path": "src/config.l",
    "content": "%%\n[0-9]+[dhms] \"TIME\"\n\"(?:\\\\[\\\\\"]|[^\"\\\\])*\" \"STRING\"\n= \"=\"\n, \",\"\n\\{ \"{\"\n\\} \"}\"\n\\[ \"[\"\n\\] \"]\"\n; \";\"\n: \":\"\naccount \"ACCOUNT\"\nauth_error_cmd \"AUTH_ERROR_CMD\"\nauth_notify_cmd \"AUTH_NOTIFY_CMD\"\nauth_notify_interval \"AUTH_NOTIFY_INTERVAL\"\nauth_uri \"AUTH_URI\"\nauth_uri_fields \"AUTH_URI_FIELDS\"\nclient_id \"CLIENT_ID\"\nclient_secret \"CLIENT_SECRET\"\nerror_notify_cmd \"ERROR_NOTIFY_CMD\"\nhttp_listen \"HTTP_LISTEN\"\nhttps_listen \"HTTPS_LISTEN\"\nlogin_hint \"LOGIN_HINT\"\nnone \"NONE\"\nrefresh_retry \"REFRESH_RETRY\"\nredirect_uri \"REDIRECT_URI\"\nrefresh_before_expiry \"REFRESH_BEFORE_EXPIRY\"\nrefresh_at_least \"REFRESH_AT_LEAST\"\nscopes \"SCOPES\"\nstartup_cmd \"STARTUP_CMD\"\ntoken_event_cmd \"TOKEN_EVENT_CMD\"\ntoken_uri \"TOKEN_URI\"\ntransient_error_if_cmd \"TRANSIENT_ERROR_IF_CMD\"\n//.*?$ ;\n[ \\t\\n\\r]+ ;\n. \"UNMATCHED\"\n"
  },
  {
    "path": "src/config.rs",
    "content": "use std::{\n    collections::HashMap, error::Error, fs::read_to_string, path::Path, sync::Arc, time::Duration,\n};\n\nuse lrlex::{lrlex_mod, DefaultLexerTypes, LRNonStreamingLexer};\nuse lrpar::{lrpar_mod, NonStreamingLexer, Span};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\nuse wincode::{SchemaRead, SchemaWrite};\n\nuse crate::config_ast;\n\nlrlex_mod!(\"config.l\");\nlrpar_mod!(\"config.y\");\n\ntype StorageT = u8;\n\n/// How many seconds before an access token's expiry do we try refreshing it?\nconst REFRESH_BEFORE_EXPIRY_DEFAULT: Duration = Duration::from_secs(90);\n/// How many seconds before we forcibly try refreshing an access token, even if it's not yet\n/// expired?\nconst REFRESH_AT_LEAST_DEFAULT: Duration = Duration::from_secs(90 * 60);\n/// How many seconds after a refresh failed in a non-permanent way before we retry refreshing?\nconst REFRESH_RETRY_DEFAULT: Duration = Duration::from_secs(40);\n/// How many seconds do we raise a notification if it only contains authorisations that have been\n/// shown before?\nconst AUTH_NOTIFY_INTERVAL_DEFAULT: u64 = 15 * 60;\n/// What is the default bind() address for the HTTP server?\nconst HTTP_LISTEN_DEFAULT: &str = \"127.0.0.1:0\";\n/// What is the default bind() address for the HTTPS server?\nconst HTTPS_LISTEN_DEFAULT: &str = \"127.0.0.1:0\";\n\n#[derive(Debug)]\npub struct Config {\n    pub accounts: HashMap<String, Arc<Account>>,\n    pub auth_notify_cmd: Option<String>,\n    pub auth_notify_interval: Duration,\n    pub error_notify_cmd: Option<String>,\n    pub http_listen: Option<String>,\n    pub https_listen: Option<String>,\n    pub transient_error_if_cmd: Option<String>,\n    refresh_at_least: Option<Duration>,\n    refresh_before_expiry: Option<Duration>,\n    refresh_retry: Option<Duration>,\n    pub startup_cmd: Option<String>,\n    pub token_event_cmd: Option<String>,\n}\n\nimpl Config {\n    /// Create a `Config` from `path`, returning `Err(String)` (containing a human readable\n    /// message) if it was unable to do so.\n    pub fn from_path(conf_path: &Path) -> Result<Self, String> {\n        let input = match read_to_string(conf_path) {\n            Ok(s) => s,\n            Err(e) => return Err(format!(\"Can't read {:?}: {}\", conf_path, e)),\n        };\n        Config::from_str(&input)\n    }\n\n    pub fn from_str(input: &str) -> Result<Self, String> {\n        let lexerdef = config_l::lexerdef();\n        let lexer = lexerdef.lexer(input);\n        let (astopt, errs) = config_y::parse(&lexer);\n        if !errs.is_empty() {\n            let msgs = errs\n                .iter()\n                .map(|e| e.pp(&lexer, &config_y::token_epp))\n                .collect::<Vec<_>>();\n            return Err(msgs.join(\"\\n\"));\n        }\n\n        let mut accounts = HashMap::new();\n        let mut auth_notify_cmd = None;\n        let mut auth_notify_interval = None;\n        let mut error_notify_cmd = None;\n        let mut http_listen = None;\n        let mut https_listen = None;\n        let mut transient_error_if_cmd = None;\n        let mut refresh_at_least = None;\n        let mut refresh_before_expiry = None;\n        let mut refresh_retry = None;\n        let mut startup_cmd = None;\n        let mut token_event_cmd = None;\n        match astopt {\n            Some(Ok(opts)) => {\n                for opt in opts {\n                    match opt {\n                        config_ast::TopLevel::Account(overall_span, name, fields) => {\n                            let act_name = unescape_str(lexer.span_str(name));\n                            accounts.insert(\n                                act_name.clone(),\n                                Arc::new(Account::from_fields(\n                                    act_name,\n                                    &lexer,\n                                    overall_span,\n                                    fields,\n                                )?),\n                            );\n                        }\n                        config_ast::TopLevel::AuthErrorCmd(span) => {\n                            return Err(error_at_span(\n                                &lexer,\n                                span,\n                                \"'auth_error_cmd' has been renamed to 'error_notify_cmd'\",\n                            ));\n                        }\n                        config_ast::TopLevel::AuthNotifyCmd(span) => {\n                            auth_notify_cmd = Some(check_not_assigned_str(\n                                &lexer,\n                                \"auth_notify_cmd\",\n                                span,\n                                auth_notify_cmd,\n                            )?)\n                        }\n                        config_ast::TopLevel::AuthNotifyInterval(span) => {\n                            auth_notify_interval =\n                                Some(time_str_to_duration(check_not_assigned_time(\n                                    &lexer,\n                                    \"auth_notify_interval\",\n                                    span,\n                                    auth_notify_interval,\n                                )?)?)\n                        }\n                        config_ast::TopLevel::ErrorNotifyCmd(span) => {\n                            error_notify_cmd = Some(check_not_assigned_str(\n                                &lexer,\n                                \"error_notify_cmd\",\n                                span,\n                                error_notify_cmd,\n                            )?)\n                        }\n                        config_ast::TopLevel::HttpListen(span) => {\n                            http_listen = Some(Some(check_not_assigned_str(\n                                &lexer,\n                                \"http_listen\",\n                                span,\n                                http_listen,\n                            )?))\n                        }\n                        config_ast::TopLevel::HttpListenNone(span) => {\n                            check_not_assigned(&lexer, \"http_listen\", span, http_listen)?;\n                            http_listen = Some(None)\n                        }\n                        config_ast::TopLevel::HttpsListen(span) => {\n                            https_listen = Some(Some(check_not_assigned_str(\n                                &lexer,\n                                \"https_listen\",\n                                span,\n                                https_listen,\n                            )?))\n                        }\n                        config_ast::TopLevel::HttpsListenNone(span) => {\n                            check_not_assigned(&lexer, \"https_listen\", span, https_listen)?;\n                            https_listen = Some(None)\n                        }\n                        config_ast::TopLevel::TransientErrorIfCmd(span) => {\n                            transient_error_if_cmd = Some(check_not_assigned_str(\n                                &lexer,\n                                \"transient_error_if_cmd\",\n                                span,\n                                transient_error_if_cmd,\n                            )?)\n                        }\n                        config_ast::TopLevel::RefreshAtLeast(span) => {\n                            refresh_at_least = Some(time_str_to_duration(check_not_assigned_time(\n                                &lexer,\n                                \"refresh_at_least\",\n                                span,\n                                refresh_at_least,\n                            )?)?)\n                        }\n                        config_ast::TopLevel::RefreshBeforeExpiry(span) => {\n                            refresh_before_expiry =\n                                Some(time_str_to_duration(check_not_assigned_time(\n                                    &lexer,\n                                    \"refresh_before_expiry\",\n                                    span,\n                                    refresh_before_expiry,\n                                )?)?)\n                        }\n                        config_ast::TopLevel::RefreshRetry(span) => {\n                            refresh_retry = Some(time_str_to_duration(check_not_assigned_time(\n                                &lexer,\n                                \"refresh_retry\",\n                                span,\n                                refresh_retry,\n                            )?)?)\n                        }\n                        config_ast::TopLevel::StartupCmd(span) => {\n                            startup_cmd = Some(check_not_assigned_str(\n                                &lexer,\n                                \"startup_cmd\",\n                                span,\n                                startup_cmd,\n                            )?)\n                        }\n                        config_ast::TopLevel::TokenEventCmd(span) => {\n                            token_event_cmd = Some(check_not_assigned_str(\n                                &lexer,\n                                \"token_event_cmd\",\n                                span,\n                                token_event_cmd,\n                            )?)\n                        }\n                    }\n                }\n            }\n            _ => unreachable!(),\n        }\n\n        if let (&Some(None), &Some(None)) = (&http_listen, &https_listen) {\n            return Err(\"Cannot set both http_listen and https_listen to 'none'\".into());\n        }\n\n        if accounts.is_empty() {\n            return Err(\"Must specify at least one account\".into());\n        }\n\n        for (act_name, act) in &accounts {\n            if act.redirect_uri.starts_with(\"https\") {\n                match https_listen {\n                    Some(Some(_)) | None => (),\n                    Some(None) => {\n                        return Err(format!(\"Account {act_name} has an 'https' redirect but the HTTPS server is set to 'none'\"));\n                    }\n                }\n            } else if act.redirect_uri.starts_with(\"http\") {\n                match http_listen {\n                    Some(Some(_)) | None => (),\n                    Some(None) => {\n                        return Err(format!(\"Account {act_name} has an 'http' redirect but the HTTP server is set to 'none'\"));\n                    }\n                }\n            }\n        }\n\n        Ok(Config {\n            accounts,\n            auth_notify_cmd,\n            auth_notify_interval: auth_notify_interval\n                .unwrap_or_else(|| Duration::from_secs(AUTH_NOTIFY_INTERVAL_DEFAULT)),\n            error_notify_cmd,\n            http_listen: http_listen.unwrap_or_else(|| Some(HTTP_LISTEN_DEFAULT.to_owned())),\n            https_listen: https_listen.unwrap_or_else(|| Some(HTTPS_LISTEN_DEFAULT.to_owned())),\n            transient_error_if_cmd,\n            refresh_at_least,\n            refresh_before_expiry,\n            refresh_retry,\n            startup_cmd,\n            token_event_cmd,\n        })\n    }\n}\n\nfn check_not_assigned<T>(\n    lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    name: &str,\n    span: Span,\n    v: Option<T>,\n) -> Result<(), String> {\n    match v {\n        None => Ok(()),\n        Some(_) => Err(error_at_span(\n            lexer,\n            span,\n            &format!(\"Mustn't specify '{name:}' more than once\"),\n        )),\n    }\n}\n\nfn check_not_assigned_str<T>(\n    lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    name: &str,\n    span: Span,\n    v: Option<T>,\n) -> Result<String, String> {\n    match v {\n        None => Ok(unescape_str(lexer.span_str(span))),\n        Some(_) => Err(error_at_span(\n            lexer,\n            span,\n            &format!(\"Mustn't specify '{name:}' more than once\"),\n        )),\n    }\n}\n\nfn check_not_assigned_time<'a, T>(\n    lexer: &'a LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    name: &str,\n    span: Span,\n    v: Option<T>,\n) -> Result<&'a str, String> {\n    match v {\n        None => Ok(lexer.span_str(span)),\n        Some(_) => Err(error_at_span(\n            lexer,\n            span,\n            &format!(\"Mustn't specify '{name:}' more than once\"),\n        )),\n    }\n}\n\nfn check_not_assigned_uri<T>(\n    lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    name: &str,\n    span: Span,\n    v: Option<T>,\n) -> Result<String, String> {\n    match v {\n        None => {\n            let s = unescape_str(lexer.span_str(span));\n            match Url::parse(&s) {\n                Ok(x) => {\n                    if x.fragment().is_some() {\n                        Err(error_at_span(\n                            lexer,\n                            span,\n                            \"URI fragments ('#...') are not allowed\",\n                        ))\n                    } else if x.scheme() == \"http\" || x.scheme() == \"https\" {\n                        Ok(s)\n                    } else {\n                        Err(error_at_span(lexer, span, \"not a valid HTTP or HTTPS URI\"))\n                    }\n                }\n                Err(e) => Err(error_at_span(lexer, span, &format!(\"Invalid URI: {e:}\"))),\n            }\n        }\n        Some(_) => Err(error_at_span(\n            lexer,\n            span,\n            &format!(\"Mustn't specify '{name:}' more than once\"),\n        )),\n    }\n}\n\nfn check_assigned<T>(\n    lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    name: &str,\n    span: Span,\n    v: Option<T>,\n) -> Result<T, String> {\n    match v {\n        Some(x) => Ok(x),\n        None => Err(error_at_span(\n            lexer,\n            span,\n            &format!(\"{name:} not specified\"),\n        )),\n    }\n}\n\n/// If you add to the, or alter the semantics of any existing, fields in this struct, you *must*\n/// check whether any of the following also need to be chnaged:\n///   * `Account::secure_eq`\n///   * `Account::dump`\n///   * `Account::secure_restoreable`\n///   * `AccountDump`\n///\n/// These functions are vital to the security guarantees pizauth makes when reloading/restoring\n/// configurations.\n#[derive(Clone, Debug)]\npub struct Account {\n    pub name: String,\n    pub auth_uri: String,\n    pub auth_uri_fields: Vec<(String, String)>,\n    pub client_id: String,\n    pub client_secret: Option<String>,\n    redirect_uri: String,\n    refresh_at_least: Option<Duration>,\n    refresh_before_expiry: Option<Duration>,\n    refresh_retry: Option<Duration>,\n    pub scopes: Vec<String>,\n    pub token_uri: String,\n}\n\nimpl Account {\n    fn from_fields(\n        name: String,\n        lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n        overall_span: Span,\n        fields: Vec<config_ast::AccountField>,\n    ) -> Result<Self, String> {\n        let mut auth_uri = None;\n        let mut auth_uri_fields = None;\n        let mut client_id = None;\n        let mut client_secret = None;\n        let mut login_hint = None;\n        let mut redirect_uri = None;\n        let mut refresh_at_least = None;\n        let mut refresh_before_expiry = None;\n        let mut refresh_retry = None;\n        let mut scopes = None;\n        let mut token_uri = None;\n\n        for f in fields {\n            match f {\n                config_ast::AccountField::AuthUri(span) => {\n                    auth_uri = Some(check_not_assigned_uri(lexer, \"auth_uri\", span, auth_uri)?)\n                }\n                config_ast::AccountField::AuthUriFields(span, spans) => {\n                    if auth_uri_fields.is_some() {\n                        debug_assert!(!spans.is_empty());\n                        return Err(error_at_span(\n                            lexer,\n                            span,\n                            \"Mustn't specify 'auth_uri_fields' more than once\",\n                        ));\n                    }\n                    auth_uri_fields = Some(\n                        spans\n                            .iter()\n                            .map(|(key_sp, val_sp)| {\n                                (\n                                    unescape_str(lexer.span_str(*key_sp)),\n                                    unescape_str(lexer.span_str(*val_sp)),\n                                )\n                            })\n                            .collect::<Vec<(String, String)>>(),\n                    );\n                }\n                config_ast::AccountField::ClientId(span) => {\n                    client_id = Some(check_not_assigned_str(lexer, \"client_id\", span, client_id)?)\n                }\n                config_ast::AccountField::ClientSecret(span) => {\n                    client_secret = Some(check_not_assigned_str(\n                        lexer,\n                        \"client_secret\",\n                        span,\n                        client_secret,\n                    )?)\n                }\n                config_ast::AccountField::LoginHint(span) => {\n                    login_hint = Some(check_not_assigned_str(\n                        lexer,\n                        \"login_hint\",\n                        span,\n                        login_hint,\n                    )?)\n                }\n                config_ast::AccountField::RedirectUri(span) => {\n                    let uri = check_not_assigned_uri(lexer, \"redirect_uri\", span, redirect_uri)?;\n                    redirect_uri = Some(uri)\n                }\n                config_ast::AccountField::RefreshAtLeast(span) => {\n                    refresh_at_least = Some(time_str_to_duration(check_not_assigned_time(\n                        lexer,\n                        \"refresh_at_least\",\n                        span,\n                        refresh_at_least,\n                    )?)?)\n                }\n                config_ast::AccountField::RefreshBeforeExpiry(span) => {\n                    refresh_before_expiry = Some(time_str_to_duration(check_not_assigned_time(\n                        lexer,\n                        \"refresh_before_expiry\",\n                        span,\n                        refresh_before_expiry,\n                    )?)?)\n                }\n                config_ast::AccountField::RefreshRetry(span) => {\n                    refresh_retry = Some(time_str_to_duration(check_not_assigned_time(\n                        lexer,\n                        \"refresh_retry\",\n                        span,\n                        refresh_retry,\n                    )?)?)\n                }\n                config_ast::AccountField::Scopes(span, spans) => {\n                    if scopes.is_some() {\n                        debug_assert!(!spans.is_empty());\n                        return Err(error_at_span(\n                            lexer,\n                            span,\n                            \"Mustn't specify 'scopes' more than once\",\n                        ));\n                    }\n                    scopes = Some(\n                        spans\n                            .iter()\n                            .map(|sp| unescape_str(lexer.span_str(*sp)))\n                            .collect::<Vec<String>>(),\n                    );\n                }\n                config_ast::AccountField::TokenUri(span) => {\n                    token_uri = Some(check_not_assigned_uri(lexer, \"token_uri\", span, token_uri)?)\n                }\n            }\n        }\n\n        let auth_uri = check_assigned(lexer, \"auth_uri\", overall_span, auth_uri)?;\n        let client_id = check_assigned(lexer, \"client_id\", overall_span, client_id)?;\n        let token_uri = check_assigned(lexer, \"token_uri\", overall_span, token_uri)?;\n\n        // We allow the deprecated `login_hint` field through but don't want to allow it to clash\n        // with a field of the same name in `auth_uri_fields`.\n        if let (Some(_), Some(auth_uri_fields)) = (&login_hint, &auth_uri_fields) {\n            if auth_uri_fields.iter().any(|(k, _)| k == \"login_hint\") {\n                return Err(error_at_span(lexer, overall_span, \"Both the 'login_hint' attribute and a 'auth_uri_fields' field with the name 'login_hint' are specified. The 'login_hint' attribute is deprecated so remove it.\"));\n            }\n        }\n\n        Ok(Account {\n            name,\n            auth_uri,\n            auth_uri_fields: auth_uri_fields.unwrap_or_default(),\n            client_id,\n            client_secret,\n            redirect_uri: redirect_uri.unwrap_or_else(|| \"http://localhost/\".to_owned()),\n            refresh_at_least,\n            refresh_before_expiry,\n            refresh_retry,\n            scopes: scopes.unwrap_or_default(),\n            token_uri,\n        })\n    }\n\n    /// Are the security relevant parts of this `Account` the same as `other`?\n    ///\n    /// Note that this is a weaker condition than \"is `self` equal to `other`\" because there are\n    /// some parts of an `Account`'s configuration that are irrelevant from a security perspective.\n    /// If you add new fields to, or change the semantics of existing fields in, `Account`, you\n    /// must reconsider this function.\n    pub fn secure_eq(&self, other: &Self) -> bool {\n        // Our definition of \"are the security relevant parts of this `Account` the same as\n        // `other`\" is roughly: if anything here changes could we end up giving out an access token\n        // that the user might send to the wrong server? Note that it is better to be safe than\n        // sorry: if in doubt, it is better to have more, rather than fewer, fields compared here.\n        self.name == other.name\n            && self.auth_uri == other.auth_uri\n            && self.auth_uri_fields == other.auth_uri_fields\n            && self.client_id == other.client_id\n            && self.client_secret == other.client_secret\n            && self.redirect_uri == other.redirect_uri\n            && self.scopes == other.scopes\n            && self.token_uri == other.token_uri\n    }\n\n    pub fn dump(&self) -> AccountDump {\n        AccountDump {\n            auth_uri: self.auth_uri.clone(),\n            auth_uri_fields: self.auth_uri_fields.clone(),\n            client_id: self.client_id.clone(),\n            client_secret: self.client_secret.clone(),\n            redirect_uri: self.redirect_uri.clone(),\n            scopes: self.scopes.clone(),\n            token_uri: self.token_uri.clone(),\n        }\n    }\n\n    /// Can this account's tokenstate safely be restored from an [AccountDump] `act_dump`? Roughly\n    /// speaking, if `act_dump` was converted into an `Account`, would that new `Account` compare\n    /// equal with `secure_eq` to `self`? If `true`, then it is safe to restore the (`self`)\n    /// `Account`'s tokenstate from a dump.\n    pub fn secure_restorable(&self, act_dump: &AccountDump) -> bool {\n        self.auth_uri == act_dump.auth_uri\n            && self.auth_uri_fields == act_dump.auth_uri_fields\n            && self.client_id == act_dump.client_id\n            && self.client_secret == act_dump.client_secret\n            && self.redirect_uri == act_dump.redirect_uri\n            && self.scopes == act_dump.scopes\n            && self.token_uri == act_dump.token_uri\n    }\n\n    pub fn redirect_uri(\n        &self,\n        http_port: Option<u16>,\n        https_port: Option<u16>,\n    ) -> Result<Url, Box<dyn Error>> {\n        assert!(http_port.is_some() || https_port.is_some());\n        let mut url = Url::parse(&self.redirect_uri)?;\n        if https_port.is_some() && self.redirect_uri.to_lowercase().starts_with(\"https\") {\n            url.set_port(https_port)\n                .map_err(|_| \"Cannot set https port\")?;\n        } else {\n            url.set_port(http_port)\n                .map_err(|_| \"Cannot set http port\")?;\n        }\n        Ok(url)\n    }\n\n    pub fn refresh_at_least(&self, config: &Config) -> Duration {\n        self.refresh_at_least\n            .or(config.refresh_at_least)\n            .unwrap_or(REFRESH_AT_LEAST_DEFAULT)\n    }\n\n    pub fn refresh_before_expiry(&self, config: &Config) -> Duration {\n        self.refresh_before_expiry\n            .or(config.refresh_before_expiry)\n            .unwrap_or(REFRESH_BEFORE_EXPIRY_DEFAULT)\n    }\n\n    pub fn refresh_retry(&self, config: &Config) -> Duration {\n        self.refresh_retry\n            .or(config.refresh_retry)\n            .unwrap_or(REFRESH_RETRY_DEFAULT)\n    }\n}\n\n#[derive(Deserialize, Serialize, SchemaRead, SchemaWrite)]\npub struct AccountDump {\n    auth_uri: String,\n    auth_uri_fields: Vec<(String, String)>,\n    client_id: String,\n    client_secret: Option<String>,\n    redirect_uri: String,\n    scopes: Vec<String>,\n    token_uri: String,\n}\n\n/// Given a time duration in the format `[0-9]+[dhms]` return a [Duration].\n///\n/// # Panics\n///\n/// If `t` is not in the format `[0-9]+[dhms]`.\nfn time_str_to_duration(t: &str) -> Result<Duration, String> {\n    fn inner(t: &str) -> Result<Duration, Box<dyn Error>> {\n        let last_char_idx = t\n            .chars()\n            .filter(|c| c.is_numeric())\n            .map(|c| c.len_utf8())\n            .sum();\n        debug_assert!(last_char_idx < t.len());\n        let num = t[..last_char_idx].parse::<u64>()?;\n        let secs = match t.chars().last().unwrap() {\n            'd' => num.checked_mul(86400).ok_or(\"Number too big\")?,\n            'h' => num.checked_mul(3600).ok_or(\"Number too big\")?,\n            'm' => num.checked_mul(60).ok_or(\"Number too big\")?,\n            's' => num,\n            _ => unreachable!(),\n        };\n        Ok(Duration::from_secs(secs))\n    }\n    inner(t).map_err(|e| format!(\"Invalid time: {e}\"))\n}\n\n/// Take a quoted string from the config file and unescape it (i.e. strip the start and end quote\n/// (\") characters and process any escape characters in the string.)\nfn unescape_str(us: &str) -> String {\n    // The regex in config.l should have guaranteed that strings start and finish with a\n    // quote character.\n    debug_assert!(us.starts_with('\"') && us.ends_with('\"'));\n    let mut s = String::new();\n    // We iterate over all characters except the opening and closing quote characters.\n    let mut i = '\"'.len_utf8();\n    while i < us.len() - '\"'.len_utf8() {\n        let c = us[i..].chars().next().unwrap();\n        if c == '\\\\' {\n            // The regex in config.l should have guaranteed that there are no unescaped quote (\")\n            // characters, but we check here just to be sure.\n            debug_assert!(i < us.len() - '\"'.len_utf8());\n            i += 1;\n            let c2 = us[i..].chars().next().unwrap();\n            debug_assert!(c2 == '\"' || c2 == '\\\\');\n            s.push(c2);\n            i += c2.len_utf8();\n        } else {\n            s.push(c);\n            i += c.len_utf8();\n        }\n    }\n    s\n}\n\n/// Return an error message pinpointing `span` as the culprit.\nfn error_at_span(\n    lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,\n    span: Span,\n    msg: &str,\n) -> String {\n    let ((line_off, col), _) = lexer.line_col(span);\n    let code = lexer\n        .span_lines_str(span)\n        .split('\\n')\n        .next()\n        .unwrap()\n        .trim();\n    format!(\n        \"Line {}, column {}:\\n  {}\\n{}\",\n        line_off,\n        col,\n        code.trim(),\n        msg\n    )\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use lrpar::Lexer;\n\n    #[test]\n    fn test_unescape_string() {\n        assert_eq!(unescape_str(\"\\\"\\\"\"), \"\");\n        assert_eq!(unescape_str(\"\\\"a\\\"\"), \"a\");\n        assert_eq!(unescape_str(\"\\\"a\\\\\\\"\\\"\"), \"a\\\"\");\n        assert_eq!(unescape_str(\"\\\"a\\\\\\\"b\\\"\"), \"a\\\"b\");\n        assert_eq!(unescape_str(\"\\\"\\\\\\\\\\\"\"), \"\\\\\");\n    }\n\n    #[test]\n    fn test_time_str_to_duration() {\n        assert_eq!(time_str_to_duration(\"0s\").unwrap(), Duration::from_secs(0));\n        assert_eq!(time_str_to_duration(\"1s\").unwrap(), Duration::from_secs(1));\n        assert_eq!(time_str_to_duration(\"1m\").unwrap(), Duration::from_secs(60));\n        assert_eq!(\n            time_str_to_duration(\"2m\").unwrap(),\n            Duration::from_secs(120)\n        );\n        assert_eq!(\n            time_str_to_duration(\"1h\").unwrap(),\n            Duration::from_secs(3600)\n        );\n        assert_eq!(\n            time_str_to_duration(\"1d\").unwrap(),\n            Duration::from_secs(86400)\n        );\n\n        assert!(time_str_to_duration(\"9223372036854775808m\").is_err());\n    }\n\n    #[test]\n    fn string_escapes() {\n        let lexerdef = config_l::lexerdef();\n        let lexemes = lexerdef.lexer(\"\\\"\\\\\\\\\\\"\").iter().collect::<Vec<_>>();\n        assert_eq!(lexemes.len(), 1);\n        let lexemes = lexerdef.lexer(\"\\\"\\\\\\\"\\\\\\\"\\\"\").iter().collect::<Vec<_>>();\n        assert_eq!(lexemes.len(), 1);\n        let lexemes = lexerdef.lexer(\"\\\"\\\\n\\\"\").iter().collect::<Vec<_>>();\n        assert_eq!(lexemes.len(), 4);\n    }\n\n    #[test]\n    fn valid_config() {\n        let c = Config::from_str(\n            r#\"\n            auth_notify_cmd = \"g\";\n            auth_notify_interval = 88m;\n            error_notify_cmd = \"j\";\n            http_listen = \"127.0.0.1:56789\";\n            transient_error_if_cmd = \"k\";\n            token_event_cmd = \"q\";\n            account \"x\" {\n                // Mandatory fields\n                auth_uri = \"http://a.com\";\n                auth_uri_fields = {\"l\": \"m\", \"n\": \"o\", \"l\": \"p\"};\n                client_id = \"b\";\n                scopes = [\"c\", \"d\"];\n                token_uri = \"http://f.com\";\n                // Optional fields\n                client_secret = \"h\";\n                login_hint = \"i\";\n                redirect_uri = \"http://e.com\";\n                refresh_at_least = 43m;\n                refresh_before_expiry = 42s;\n                refresh_retry = 33s;\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.error_notify_cmd, Some(\"j\".to_owned()));\n        assert_eq!(c.auth_notify_cmd, Some(\"g\".to_owned()));\n        assert_eq!(c.auth_notify_interval, Duration::from_secs(88 * 60));\n        assert_eq!(c.http_listen, Some(\"127.0.0.1:56789\".to_owned()));\n        assert_eq!(c.transient_error_if_cmd, Some(\"k\".to_owned()));\n        assert_eq!(c.token_event_cmd, Some(\"q\".to_owned()));\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.auth_uri, \"http://a.com\");\n        assert_eq!(\n            &act.auth_uri_fields,\n            &[\n                (\"l\".to_owned(), \"m\".to_owned()),\n                (\"n\".to_owned(), \"o\".to_owned()),\n                (\"l\".to_owned(), \"p\".to_owned())\n            ]\n        );\n        assert_eq!(act.client_id, \"b\");\n        assert_eq!(act.client_secret, Some(\"h\".to_owned()));\n        assert_eq!(act.redirect_uri, \"http://e.com\");\n        assert_eq!(act.token_uri, \"http://f.com\");\n        assert_eq!(&act.scopes, &[\"c\".to_owned(), \"d\".to_owned()]);\n        assert_eq!(act.refresh_at_least, Some(Duration::from_secs(43 * 60)));\n        assert_eq!(act.refresh_before_expiry, Some(Duration::from_secs(42)));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(33));\n    }\n\n    #[test]\n    fn at_least_one_account() {\n        assert_eq!(\n            Config::from_str(\"\").unwrap_err().as_str(),\n            \"Must specify at least one account\"\n        );\n    }\n\n    #[test]\n    fn invalid_time() {\n        match Config::from_str(\"auth_notify_interval = 18446744073709551616s;\") {\n            Err(s) if s.contains(\"Invalid time: number too large\") => (),\n            _ => panic!(),\n        }\n    }\n\n    #[test]\n    fn dup_fields() {\n        match Config::from_str(r#\"auth_notify_cmd = \"a\"; auth_notify_cmd = \"a\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'auth_notify_cmd' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(\"auth_notify_interval = 1s; auth_notify_interval = 2s;\") {\n            Err(s) if s.contains(\"Mustn't specify 'auth_notify_interval' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"error_notify_cmd = \"a\"; error_notify_cmd = \"a\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'error_notify_cmd' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"token_event_cmd = \"a\"; token_event_cmd = \"a\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'token_event_cmd' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"transient_error_if_cmd = \"a\"; transient_error_if_cmd = \"b\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'transient_error_if_cmd' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"http_listen = \"a\"; http_listen = \"b\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'http_listen' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"http_listen = none; http_listen = \"a\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'http_listen' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"https_listen = \"a\"; https_listen = \"b\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'https_listen' more than once\") => (),\n            _ => panic!(),\n        }\n        match Config::from_str(r#\"https_listen = none; https_listen = \"a\";\"#) {\n            Err(s) if s.contains(\"Mustn't specify 'https_listen' more than once\") => (),\n            _ => panic!(),\n        }\n\n        fn account_dup(field: &str, values: &[&str]) {\n            let c = format!(\n                \"account \\\"x\\\" {{ {} }}\",\n                values\n                    .iter()\n                    .map(|v| format!(\"{field:} = {v:};\"))\n                    .collect::<Vec<_>>()\n                    .join(\" \")\n            );\n            match Config::from_str(&c) {\n                Err(s) if s.contains(&format!(\"Mustn't specify '{field:}' more than once\")) => (),\n                Err(e) => panic!(\"{e:}\"),\n                _ => panic!(),\n            }\n        }\n\n        account_dup(\"auth_uri\", &[r#\"\"http://a.com/\"\"#, r#\"\"http://b.com/\"\"#]);\n        account_dup(\"auth_uri_fields\", &[r#\"{\"a\": \"b\"}\"#, r#\"{\"c\": \"d\"}\"#]);\n        account_dup(\"client_id\", &[r#\"\"a\"\"#, r#\"\"b\"\"#]);\n        account_dup(\"client_secret\", &[r#\"\"a\"\"#, r#\"\"b\"\"#]);\n        account_dup(\"login_hint\", &[r#\"\"a\"\"#, r#\"\"b\"\"#]);\n        account_dup(\n            \"redirect_uri\",\n            &[r#\"\"http://a.com/\"\"#, r#\"\"http://b.com/\"\"#],\n        );\n        account_dup(\"refresh_before_expiry\", &[\"1m\", \"2m\"]);\n        account_dup(\"refresh_at_least\", &[\"1m\", \"2m\"]);\n        account_dup(\"scopes\", &[r#\"[\"a\"]\"#, r#\"[\"b\"]\"#]);\n        account_dup(\"token_uri\", &[r#\"\"http://a.com/\"\"#, r#\"\"http://b.com/\"\"#]);\n    }\n\n    #[test]\n    fn one_of_http_or_https() {\n        match Config::from_str(\n            r#\"\n            http_listen = none;\n            https_listen = none;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                token_uri = \"http://f.com\";\n            }\n        \"#,\n        ) {\n            Err(e) if e.contains(\"Cannot set both http_listen and https_listen to 'none'\") => (),\n            Err(e) => panic!(\"{e:?}\"),\n            _ => panic!(),\n        }\n    }\n\n    #[test]\n    fn http_or_https_redirect_uris_only() {\n        match Config::from_str(\n            r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                redirect_uri = \"httpx://\";\n                token_uri = \"http://f.com\";\n            }\n        \"#,\n        ) {\n            Err(e) if e.contains(\"not a valid HTTP or HTTPS URI\") => (),\n            Err(e) => panic!(\"{e:?}\"),\n            _ => panic!(),\n        }\n\n        match Config::from_str(\n            r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                redirect_uri = \"ftp://blah/\";\n                token_uri = \"http://f.com\";\n            }\n        \"#,\n        ) {\n            Err(e) if e.contains(\"not a valid HTTP or HTTPS URI\") => (),\n            Err(e) => panic!(\"{e:?}\"),\n            _ => panic!(),\n        }\n    }\n\n    #[test]\n    fn correct_listen_for_account() {\n        match Config::from_str(\n            r#\"\n            http_listen = none;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                token_uri = \"http://f.com\";\n            }\n        \"#,\n        ) {\n            Err(e)\n                if e.contains(\n                    \"Account x has an 'http' redirect but the HTTP server is set to 'none'\",\n                ) =>\n            {\n                ()\n            }\n            Err(e) => panic!(\"{e:?}\"),\n            _ => panic!(),\n        }\n        match Config::from_str(\n            r#\"\n            https_listen = none;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                redirect_uri = \"https://c.com\";\n                token_uri = \"http://f.com\";\n            }\n        \"#,\n        ) {\n            Err(e)\n                if e.contains(\n                    \"Account x has an 'https' redirect but the HTTPS server is set to 'none'\",\n                ) =>\n            {\n                ()\n            }\n            Err(e) => panic!(\"{e:?}\"),\n            _ => panic!(),\n        }\n    }\n\n    #[test]\n    fn invalid_uris() {\n        fn invalid_uri(field: &str) {\n            let c = format!(r#\"account \"x\" {{ {field} = \"blah\"; }}\"#);\n            match Config::from_str(&c) {\n                Err(e) if e.contains(\"Invalid URI\") => (),\n                Err(e) => panic!(\"{e:}\"),\n                _ => panic!(),\n            }\n        }\n\n        invalid_uri(\"auth_uri\");\n        invalid_uri(\"redirect_uri\");\n        invalid_uri(\"token_uri\");\n    }\n\n    #[test]\n    fn valid_https_config() {\n        let c = Config::from_str(\n            r#\"\n            https_listen = \"127.0.0.1:56789\";\n            account \"x\" {\n                // Mandatory fields\n                auth_uri = \"http://a.com\";\n                auth_uri_fields = {\"l\": \"m\", \"n\": \"o\", \"l\": \"p\"};\n                client_id = \"b\";\n                scopes = [\"c\", \"d\"];\n                token_uri = \"http://f.com\";\n                // Optional fields\n                redirect_uri = \"https://e.com\";\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.https_listen, Some(\"127.0.0.1:56789\".to_owned()));\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.redirect_uri, \"https://e.com\");\n        let uri = act.redirect_uri(Some(0), Some(56789)).unwrap();\n        assert_eq!(uri.scheme(), \"https\");\n        assert_eq!(uri.port(), Some(56789));\n        assert_eq!(uri.host_str(), Some(\"e.com\"));\n    }\n\n    #[test]\n    fn mandatory_account_fields() {\n        let fields = &[\n            (\"auth_uri\", r#\"\"http://a.com/\"\"#),\n            (\"client_id\", r#\"\"a\"\"#),\n            (\"token_uri\", r#\"\"http://b.com/\"\"#),\n        ];\n\n        fn combine(fields: &[(&str, &str)]) -> String {\n            fields\n                .iter()\n                .map(|(k, v)| format!(\"{k:} = {v:};\"))\n                .collect::<Vec<_>>()\n                .join(\"\\n\")\n        }\n\n        assert!(Config::from_str(&format!(r#\"account \"a\" {{ {} }}\"#, combine(fields))).is_ok());\n        for i in 0..fields.len() {\n            let mut f = fields.to_vec();\n            f.remove(i);\n            match Config::from_str(&format!(r#\"account \"a\" {{ {} }}\"#, combine(&f))) {\n                Err(e) if e.contains(\"not specified\") => (),\n                Err(e) => panic!(\"{e:}\"),\n                e => panic!(\"{e:?}\"),\n            }\n        }\n    }\n\n    #[test]\n    fn local_overrides() {\n        // Defaults only\n        let c = Config::from_str(\n            r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                scopes = [\"c\"];\n                token_uri = \"http://d.com\";\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.transient_error_if_cmd, None);\n        assert_eq!(c.refresh_at_least, None);\n        assert_eq!(c.refresh_before_expiry, None);\n        assert_eq!(c.refresh_retry, None);\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.refresh_at_least(&c), REFRESH_AT_LEAST_DEFAULT);\n        assert_eq!(act.refresh_before_expiry(&c), REFRESH_BEFORE_EXPIRY_DEFAULT);\n        assert_eq!(act.refresh_retry(&c), REFRESH_RETRY_DEFAULT);\n\n        // Global only\n        let c = Config::from_str(\n            r#\"\n            transient_error_if_cmd = \"e\";\n            refresh_at_least = 1s;\n            refresh_before_expiry = 2s;\n            refresh_retry = 3s;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                scopes = [\"c\"];\n                token_uri = \"http://d.com\";\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.transient_error_if_cmd, Some(\"e\".to_owned()));\n        assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1)));\n        assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2)));\n        assert_eq!(c.refresh_retry, Some(Duration::from_secs(3)));\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(3));\n\n        // Local only\n        let c = Config::from_str(\n            r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                scopes = [\"c\"];\n                token_uri = \"http://d.com\";\n                refresh_at_least = 1s;\n                refresh_before_expiry = 2s;\n                refresh_retry = 3s;\n            }\n        \"#,\n        )\n        .unwrap();\n\n        assert_eq!(c.transient_error_if_cmd, None);\n        assert_eq!(c.refresh_at_least, None);\n        assert_eq!(c.refresh_before_expiry, None);\n        assert_eq!(c.refresh_retry, None);\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(3));\n\n        // Local overrides global\n        let c = Config::from_str(\n            r#\"\n            transient_error_if_cmd = \"e\";\n            refresh_at_least = 1s;\n            refresh_before_expiry = 2s;\n            refresh_retry = 3s;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                scopes = [\"c\"];\n                token_uri = \"http://d.com\";\n                refresh_at_least = 4s;\n                refresh_before_expiry = 5s;\n                refresh_retry = 6s;\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.transient_error_if_cmd, Some(\"e\".to_owned()));\n        assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1)));\n        assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2)));\n        assert_eq!(c.refresh_retry, Some(Duration::from_secs(3)));\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(4));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(5));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(6));\n\n        // Local overrides global\n        let c = Config::from_str(\n            r#\"\n            transient_error_if_cmd = \"e\";\n            refresh_at_least = 1s;\n            refresh_before_expiry = 2s;\n            refresh_retry = 3s;\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                scopes = [\"c\"];\n                token_uri = \"http://d.com\";\n                refresh_at_least = 4s;\n                refresh_before_expiry = 5s;\n                refresh_retry = 6s;\n            }\n            account \"y\" {\n                auth_uri = \"http://g.com\";\n                client_id = \"h\";\n                scopes = [\"i\"];\n                token_uri = \"http://j.com\";\n                refresh_at_least = 7s;\n                refresh_before_expiry = 8s;\n                refresh_retry = 9s;\n            }\n            account \"z\" {\n                auth_uri = \"http://g.com\";\n                client_id = \"h\";\n                scopes = [\"i\"];\n                token_uri = \"http://j.com\";\n            }\n        \"#,\n        )\n        .unwrap();\n        assert_eq!(c.transient_error_if_cmd, Some(\"e\".to_owned()));\n        assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1)));\n        assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2)));\n        assert_eq!(c.refresh_retry, Some(Duration::from_secs(3)));\n\n        let act = &c.accounts[\"x\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(4));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(5));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(6));\n\n        let act = &c.accounts[\"y\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(7));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(8));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(9));\n\n        let act = &c.accounts[\"z\"];\n        assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1));\n        assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2));\n        assert_eq!(act.refresh_retry(&c), Duration::from_secs(3));\n    }\n\n    #[test]\n    fn login_hint_mutually_exclusive_query_field() {\n        let c = r#\"account \"x\" {\n            auth_uri = \"http://a.com/\";\n            auth_uri_fields = { \"login_hint\": \"e\" };\n            client_id = \"b\";\n            token_uri = \"https://c.com/\";\n            login_hint = \"d\";\n          }\"#;\n        match Config::from_str(c) {\n            Err(e) if e.contains(\"Both the 'login_hint' attribute and a 'auth_uri_fields' field with the name 'login_hint' are specified. The 'login_hint' attribute is deprecated so remove it.\") => (),\n            Err(e) => panic!(\"{e:}\"),\n            _ => panic!(),\n        }\n    }\n\n    #[test]\n    fn endpoints_no_fragment() {\n        let c = r#\"account \"x\" {\n            auth_uri = \"http://a.com/#a\";\n            auth_uri_fields = { \"login_hint\": \"e\" };\n            client_id = \"b\";\n            token_uri = \"https://c.com/\";\n            login_hint = \"d\";\n          }\"#;\n        match Config::from_str(c) {\n            Err(e) if e.contains(\"URI fragments ('#...') are not allowed\") => (),\n            Err(e) => panic!(\"{e:}\"),\n            _ => panic!(),\n        }\n\n        let c = r#\"account \"x\" {\n            auth_uri = \"http://a.com/\";\n            auth_uri_fields = { \"login_hint\": \"e\" };\n            client_id = \"b\";\n            token_uri = \"https://c.com/#c\";\n            login_hint = \"d\";\n          }\"#;\n        match Config::from_str(c) {\n            Err(e) if e.contains(\"URI fragments ('#...') are not allowed\") => (),\n            Err(e) => panic!(\"{e:}\"),\n            _ => panic!(),\n        }\n    }\n}\n"
  },
  {
    "path": "src/config.y",
    "content": "%start TopLevels\n%avoid_insert \"STRING\"\n%epp TIME \"<time>[dhms]\"\n%expect-unused Unmatched \"UNMATCHED\"\n\n%%\n\nTopLevels -> Result<Vec<TopLevel>, ()>:\n    TopLevels TopLevel { flattenr($1, $2) }\n  | { Ok(vec![]) }\n  ;\n\nTopLevel -> Result<TopLevel, ()>:\n    \"ACCOUNT\" \"STRING\" \"{\" AccountFields \"}\" { Ok(TopLevel::Account($span, map_err($2)?, $4?)) }\n  | \"AUTH_ERROR_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::AuthErrorCmd($span)) }\n  | \"AUTH_NOTIFY_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::AuthNotifyCmd(map_err($3)?)) }\n  | \"AUTH_NOTIFY_INTERVAL\" \"=\" \"TIME\" \";\" { Ok(TopLevel::AuthNotifyInterval(map_err($3)?)) }\n  | \"ERROR_NOTIFY_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::ErrorNotifyCmd(map_err($3)?)) }\n  | \"HTTP_LISTEN\" \"=\" \"NONE\" \";\" { Ok(TopLevel::HttpListenNone(map_err($3)?)) }\n  | \"HTTP_LISTEN\" \"=\" \"STRING\" \";\" { Ok(TopLevel::HttpListen(map_err($3)?)) }\n  | \"HTTPS_LISTEN\" \"=\" \"NONE\" \";\" { Ok(TopLevel::HttpsListenNone(map_err($3)?)) }\n  | \"HTTPS_LISTEN\" \"=\" \"STRING\" \";\" { Ok(TopLevel::HttpsListen(map_err($3)?)) }\n  | \"TRANSIENT_ERROR_IF_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::TransientErrorIfCmd(map_err($3)?)) }\n  | \"REFRESH_AT_LEAST\" \"=\" \"TIME\" \";\" { Ok(TopLevel::RefreshAtLeast(map_err($3)?)) }\n  | \"REFRESH_BEFORE_EXPIRY\" \"=\" \"TIME\" \";\" { Ok(TopLevel::RefreshBeforeExpiry(map_err($3)?)) }\n  | \"REFRESH_RETRY\" \"=\" \"TIME\" \";\" { Ok(TopLevel::RefreshRetry(map_err($3)?)) }\n  | \"STARTUP_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::StartupCmd(map_err($3)?)) }\n  | \"TOKEN_EVENT_CMD\" \"=\" \"STRING\" \";\" { Ok(TopLevel::TokenEventCmd(map_err($3)?)) }\n  ;\n\nAccountFields -> Result<Vec<AccountField>, ()>:\n    AccountFields AccountField { flattenr($1, $2) }\n  | { Ok(vec![]) }\n  ;\n\nAccountField -> Result<AccountField, ()>:\n    \"AUTH_URI\" \"=\" \"STRING\" \";\" { Ok(AccountField::AuthUri(map_err($3)?)) }\n  | \"AUTH_URI_FIELDS\" \"=\" \"{\" AuthUriFields \"}\" \";\" { Ok(AccountField::AuthUriFields($1.unwrap_or_else(|x| x).span(), $4?)) }\n  | \"CLIENT_ID\" \"=\" \"STRING\" \";\" { Ok(AccountField::ClientId(map_err($3)?)) }\n  | \"CLIENT_SECRET\" \"=\" \"STRING\" \";\" { Ok(AccountField::ClientSecret(map_err($3)?)) }\n  | \"LOGIN_HINT\" \"=\" \"STRING\" \";\" { Ok(AccountField::LoginHint(map_err($3)?)) }\n  | \"REDIRECT_URI\" \"=\" \"STRING\" \";\" { Ok(AccountField::RedirectUri(map_err($3)?)) }\n  | \"REFRESH_AT_LEAST\" \"=\" \"TIME\" \";\" { Ok(AccountField::RefreshAtLeast(map_err($3)?)) }\n  | \"REFRESH_BEFORE_EXPIRY\" \"=\" \"TIME\" \";\" { Ok(AccountField::RefreshBeforeExpiry(map_err($3)?)) }\n  | \"REFRESH_RETRY\" \"=\" \"TIME\" \";\" { Ok(AccountField::RefreshRetry(map_err($3)?)) }\n  | \"SCOPES\" \"=\" \"[\" Scopes \"]\" \";\" { Ok(AccountField::Scopes($1.unwrap_or_else(|x| x).span(), $4?)) }\n  | \"TOKEN_URI\" \"=\" \"STRING\" \";\" { Ok(AccountField::TokenUri(map_err($3)?)) }\n  ;\n\nAuthUriFields -> Result<Vec<(Span, Span)>, ()>:\n    AuthUriFields \",\" \"STRING\" \":\" \"STRING\" {\n      let mut spans = $1?;\n      spans.push((map_err($3)?, map_err($5)?));\n      Ok(spans)\n    }\n  | \"STRING\" \":\" \"STRING\" { Ok(vec![(map_err($1)?, map_err($3)?)]) }\n  | { Ok(vec![]) }\n  ;\n\nScopes -> Result<Vec<Span>, ()>:\n    Scopes \",\" \"STRING\" {\n      let mut spans = $1?;\n      spans.push(map_err($3)?);\n      Ok(spans)\n    }\n  | \"STRING\" { Ok(vec![map_err($1)?]) }\n  | { Ok(vec![]) }\n  ;\n\n// This rule helps turn lexing errors into parsing errors.\nUnmatched -> ():\n    \"UNMATCHED\" { }\n  ;\n\n%%\n\nuse lrlex::DefaultLexeme;\nuse lrpar::Span;\n\ntype StorageT = u8;\n\nuse crate::config_ast::{AccountField, TopLevel};\n\nfn map_err(r: Result<DefaultLexeme<StorageT>, DefaultLexeme<StorageT>>)\n    -> Result<Span, ()>\n{\n    r.map(|x| x.span()).map_err(|_| ())\n}\n\n/// Flatten `rhs` into `lhs`.\nfn flattenr<T>(lhs: Result<Vec<T>, ()>, rhs: Result<T, ()>) -> Result<Vec<T>, ()> {\n    let mut flt = lhs?;\n    flt.push(rhs?);\n    Ok(flt)\n}\n"
  },
  {
    "path": "src/config_ast.rs",
    "content": "use lrpar::Span;\n\npub enum TopLevel {\n    Account(Span, Span, Vec<AccountField>),\n    AuthErrorCmd(Span),\n    AuthNotifyCmd(Span),\n    AuthNotifyInterval(Span),\n    ErrorNotifyCmd(Span),\n    HttpListen(Span),\n    HttpListenNone(Span),\n    HttpsListen(Span),\n    HttpsListenNone(Span),\n    TransientErrorIfCmd(Span),\n    RefreshAtLeast(Span),\n    RefreshBeforeExpiry(Span),\n    RefreshRetry(Span),\n    StartupCmd(Span),\n    TokenEventCmd(Span),\n}\n\npub enum AccountField {\n    AuthUri(Span),\n    AuthUriFields(Span, Vec<(Span, Span)>),\n    ClientId(Span),\n    ClientSecret(Span),\n    LoginHint(Span),\n    RedirectUri(Span),\n    RefreshAtLeast(Span),\n    RefreshBeforeExpiry(Span),\n    RefreshRetry(Span),\n    Scopes(Span, Vec<Span>),\n    TokenUri(Span),\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "#![allow(clippy::derive_partial_eq_without_eq)]\n#![allow(clippy::too_many_arguments)]\n#![allow(clippy::type_complexity)]\n\nmod compat;\nmod config;\nmod config_ast;\nmod server;\nmod shell_cmd;\nmod user_sender;\n\nuse std::{\n    env::{self, current_exe},\n    fs,\n    io::{stdout, Write},\n    os::unix::{fs::PermissionsExt, net::UnixStream},\n    path::PathBuf,\n    process, thread,\n    time::Duration,\n};\n\nuse getopts::Options;\nuse log::error;\nuse nix::{\n    fcntl::AT_FDCWD,\n    sys::{\n        stat::{utimensat, UtimensatFlags},\n        time::TimeSpec,\n    },\n};\n#[cfg(target_os = \"openbsd\")]\nuse pledge::pledge;\nuse serde_json::json;\nuse server::sock_path;\nuse whoami::username;\n\nuse compat::daemon;\nuse config::Config;\nuse user_sender::show_token;\n\n/// Name of cache directory within $XDG_DATA_HOME.\nconst PIZAUTH_CACHE_LEAF: &str = \"pizauth\";\n/// Name of socket file within $XDG_DATA_HOME/PIZAUTH_CACHE_LEAF.\nconst PIZAUTH_CACHE_SOCK_LEAF: &str = \"pizauth.sock\";\n/// Name of `pizauth.conf` file relative to $XDG_CONFIG_HOME.\nconst PIZAUTH_CONF_LEAF: &str = \"pizauth.conf\";\n\nfn progname() -> String {\n    match current_exe() {\n        Ok(p) => p\n            .file_name()\n            .map(|x| x.to_str().unwrap_or(\"pizauth\"))\n            .unwrap_or(\"pizauth\")\n            .to_owned(),\n        Err(_) => \"pizauth\".to_owned(),\n    }\n}\n\n/// Exit with a fatal error: only to be called before the log crate is setup.\nfn fatal(msg: &str) -> ! {\n    eprintln!(\"{msg:}\");\n    process::exit(1);\n}\n\n/// Print out program usage then exit. This function must not be called after daemonisation.\nfn usage() -> ! {\n    let pn = progname();\n    eprintln!(\n        \"Usage:\\n  {pn:} dump\\n  {pn:} info [-j]\\n  {pn:} refresh [-u] <account>\\n  {pn:} restore\\n  {pn:} reload\\n  {pn:} revoke <account>\\n  {pn:} server [-c <config-path>] [-dv]\\n  {pn:} show [-u] <account>\\n  {pn:} shutdown\\n  {pn:} status\"\n    );\n    process::exit(1)\n}\n\nfn cache_path() -> PathBuf {\n    let mut p = PathBuf::new();\n    match env::var_os(\"XDG_RUNTIME_DIR\") {\n        Some(s) => p.push(s),\n        None => {\n            match env::var_os(\"TMPDIR\") {\n                Some(s) => p.push(s),\n                None => p.push(\"/tmp\"),\n            }\n            p.push(format!(\n                \"runtime-{}\",\n                username().unwrap_or_else(|_| \"unknown-user\".to_owned())\n            ));\n        }\n    }\n\n    let md = |p: &PathBuf| {\n        if !p.exists() {\n            fs::create_dir(p).unwrap_or_else(|e| fatal(&format!(\"Can't create cache dir: {e}\")));\n        }\n        fs::set_permissions(p, PermissionsExt::from_mode(0o700)).unwrap_or_else(|_| {\n            fatal(&format!(\n                \"Can't set permissions for {} to 0700 (octal)\",\n                p.to_str()\n                    .unwrap_or(\"<path cannot be represented as UTF-8>\")\n            ))\n        });\n    };\n\n    md(&p);\n    p.push(PIZAUTH_CACHE_LEAF);\n    md(&p);\n\n    p\n}\n\nfn conf_path(matches: &getopts::Matches) -> PathBuf {\n    match matches.opt_str(\"c\") {\n        Some(p) => PathBuf::from(&p),\n        None => {\n            let mut p = PathBuf::new();\n            match env::var_os(\"XDG_CONFIG_HOME\") {\n                Some(s) => p.push(s),\n                None => match env::var_os(\"HOME\") {\n                    Some(s) => {\n                        p.push(s);\n                        p.push(\".config\")\n                    }\n                    None => fatal(\"Neither $XDG_CONFIG_HOME or $HOME set\"),\n                },\n            }\n            p.push(PIZAUTH_CONF_LEAF);\n            if !p.is_file() {\n                fatal(&format!(\n                    \"No config file found at {}\",\n                    p.to_str().unwrap_or(\"pizauth.conf\")\n                ));\n            }\n            p\n        }\n    }\n}\n\nfn main() {\n    // Generic pledge support for all pizauth's commands. Note that the server later restricts\n    // these further.\n    #[cfg(target_os = \"openbsd\")]\n    pledge(\n        \"stdio rpath wpath cpath inet fattr flock unix dns proc ps exec unveil\",\n        None,\n    )\n    .unwrap();\n\n    let args: Vec<String> = env::args().collect();\n    if args.len() < 2 {\n        usage();\n    }\n    let mut opts = Options::new();\n    opts.optflag(\"h\", \"help\", \"\")\n        .optflagmulti(\"v\", \"verbose\", \"\");\n\n    let cache_path = cache_path();\n    match args[1].as_str() {\n        \"dump\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            match user_sender::dump(&cache_path) {\n                Ok(d) => {\n                    stdout().write_all(&d).ok();\n                }\n                Err(e) => {\n                    error!(\"{e:}\");\n                    process::exit(1);\n                }\n            }\n        }\n        \"info\" => {\n            let matches = opts\n                .optflagopt(\"c\", \"config\", \"Path to pizauth.conf.\", \"<conf-path>\")\n                .optflag(\"j\", \"\", \"JSON output.\")\n                .parse(&args[2..])\n                .unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n            let svj = user_sender::server_info(&cache_path).ok();\n            let progname = progname();\n            let cache_path = cache_path\n                .to_str()\n                .unwrap_or(\"<path cannot be represented as UTF-8>\");\n            let conf_path = conf_path(&matches);\n            let conf_path = conf_path\n                .to_str()\n                .unwrap_or(\"<path cannot be represented as UTF-8>\");\n            let ver = env!(\"CARGO_PKG_VERSION\");\n            if matches.opt_present(\"j\") {\n                let mut j = json!({\n                    \"cache_directory\": cache_path,\n                    \"config_file\": conf_path,\n                    \"executed_as\": progname,\n                    \"info_format_version\": 2,\n                    \"pizauth_version\": ver\n                });\n                let svj = match svj {\n                    Some(x) => json!({ \"server_running\": true, \"server_info\": x}),\n                    None => json!({ \"server_running\": false }),\n                };\n                j.as_object_mut()\n                    .unwrap()\n                    .extend(svj.as_object().unwrap().clone());\n                println!(\"{}\", j);\n            } else {\n                println!(\"{progname} version {ver}:\\n  cache directory: {cache_path}\\n  config file: {conf_path}\");\n                if let Some(svj) = svj {\n                    println!(\n                        \"server running:\\n  HTTP port: {}\\n  HTTPS port: {}\",\n                        svj[\"http_port\"].as_str().unwrap(),\n                        svj[\"https_port\"].as_str().unwrap()\n                    );\n                    if let Some(x) = svj.get(\"https_pub_key\") {\n                        println!(\"  HTTPS public key: {}\", x.as_str().unwrap());\n                    }\n                } else {\n                    println!(\"server not running\");\n                }\n            }\n        }\n        \"refresh\" => {\n            let matches = opts\n                .optflag(\"u\", \"\", \"Don't display authorisation URLs.\")\n                .parse(&args[2..])\n                .unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || matches.free.len() != 1 {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            let with_url = !matches.opt_present(\"u\");\n            if let Err(e) = user_sender::refresh(&cache_path, &matches.free[0], with_url) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"reload\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            if let Err(e) = user_sender::reload(&cache_path) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"restore\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            if let Err(e) = user_sender::restore(&cache_path) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"revoke\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || matches.free.len() != 1 {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            if let Err(e) = user_sender::revoke(&cache_path, &matches.free[0]) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"server\" => {\n            let matches = opts\n                .optflagopt(\"c\", \"config\", \"Path to pizauth.conf.\", \"<conf-path>\")\n                .optflag(\"d\", \"\", \"Don't detach from the terminal.\")\n                .parse(&args[2..])\n                .unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n\n            let sock_path = sock_path(&cache_path);\n            if sock_path.exists() {\n                // Is an existing authenticator running?\n                if UnixStream::connect(&sock_path).is_ok() {\n                    eprintln!(\"pizauth authenticator already running\");\n                    process::exit(1);\n                }\n                fs::remove_file(&sock_path).ok();\n            }\n\n            // The XDG spec says of `$XDG_RUNTIME_DIR` (where our socket file will live):\n            //   Files in this directory MAY be subjected to periodic clean-up. To ensure that your files\n            //   are not removed, they should have their access time timestamp modified at least once every\n            //   6 hours of monotonic time\n            let sock_path_cl = sock_path.clone();\n            thread::spawn(move || loop {\n                thread::sleep(Duration::from_secs(6 * 60 * 60));\n                let _ = utimensat(\n                    AT_FDCWD,\n                    &sock_path_cl,\n                    &TimeSpec::UTIME_NOW,\n                    &TimeSpec::UTIME_NOW,\n                    UtimensatFlags::NoFollowSymlink,\n                );\n            });\n\n            let conf_path = conf_path(&matches);\n            let conf = Config::from_path(&conf_path).unwrap_or_else(|m| fatal(&m));\n\n            let daemonise = !matches.opt_present(\"d\");\n            if daemonise {\n                let formatter = syslog::Formatter3164 {\n                    process: progname(),\n                    ..Default::default()\n                };\n                let logger = syslog::unix(formatter)\n                    .unwrap_or_else(|e| fatal(&format!(\"Cannot connect to syslog: {e:}\")));\n                let levelfilter = match matches.opt_count(\"v\") {\n                    0 => log::LevelFilter::Error,\n                    1 => log::LevelFilter::Warn,\n                    2 => log::LevelFilter::Info,\n                    3 => log::LevelFilter::Debug,\n                    _ => log::LevelFilter::Trace,\n                };\n                log::set_boxed_logger(Box::new(syslog::BasicLogger::new(logger)))\n                    .map(|()| log::set_max_level(levelfilter))\n                    .unwrap_or_else(|e| fatal(&format!(\"Cannot set logger: {e:}\")));\n                daemon(true, false).unwrap_or_else(|e| fatal(&format!(\"Cannot daemonise: {e:}\")));\n            } else {\n                stderrlog::new()\n                    .module(module_path!())\n                    .verbosity(matches.opt_count(\"v\"))\n                    .init()\n                    .unwrap();\n            }\n            if let Err(e) = server::server(conf_path, conf, cache_path.as_path()) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"show\" => {\n            let matches = opts\n                .optflag(\"u\", \"\", \"Don't display authorisation URLs.\")\n                .parse(&args[2..])\n                .unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") {\n                usage();\n            }\n            if matches.free.len() != 1 {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            let account = matches.free[0].as_str();\n            if let Err(e) = show_token(cache_path.as_path(), account, !matches.opt_present(\"u\")) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"shutdown\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") || !matches.free.is_empty() {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            if let Err(e) = user_sender::shutdown(&cache_path) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        \"status\" => {\n            let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());\n            if matches.opt_present(\"h\") {\n                usage();\n            }\n            if !matches.free.is_empty() {\n                usage();\n            }\n            stderrlog::new()\n                .module(module_path!())\n                .verbosity(matches.opt_count(\"v\"))\n                .init()\n                .unwrap();\n            if let Err(e) = user_sender::status(cache_path.as_path()) {\n                error!(\"{e:}\");\n                process::exit(1);\n            }\n        }\n        _ => usage(),\n    }\n}\n"
  },
  {
    "path": "src/server/eventer.rs",
    "content": "use std::{\n    collections::VecDeque,\n    error::Error,\n    fmt::{self, Display, Formatter},\n    sync::{Arc, Condvar, Mutex},\n    thread,\n    time::Duration,\n};\n\nuse log::error;\n\nuse crate::{server::AuthenticatorState, shell_cmd::shell_cmd};\n\n/// How long to run `token_event_cmd`s before killing them?\nconst TOKEN_EVENT_CMD_TIMEOUT: Duration = Duration::from_secs(10);\n\npub enum TokenEvent {\n    Invalidated,\n    New,\n    Refresh,\n    Revoked,\n}\n\nimpl Display for TokenEvent {\n    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {\n        match self {\n            TokenEvent::Invalidated => write!(f, \"token_invalidated\"),\n            TokenEvent::New => write!(f, \"token_new\"),\n            TokenEvent::Refresh => write!(f, \"token_refreshed\"),\n            TokenEvent::Revoked => write!(f, \"token_revoked\"),\n        }\n    }\n}\n\npub struct Eventer {\n    pred: Mutex<bool>,\n    condvar: Condvar,\n    event_queue: Mutex<VecDeque<(String, TokenEvent)>>,\n}\n\nimpl Eventer {\n    pub fn new() -> Result<Self, Box<dyn Error>> {\n        Ok(Eventer {\n            pred: Mutex::new(false),\n            condvar: Condvar::new(),\n            event_queue: Mutex::new(VecDeque::new()),\n        })\n    }\n\n    pub fn eventer(self: Arc<Self>, pstate: Arc<AuthenticatorState>) -> Result<(), Box<dyn Error>> {\n        thread::spawn(move || loop {\n            let mut eventer_lk = self.pred.lock().unwrap();\n            while !*eventer_lk {\n                eventer_lk = self.condvar.wait(eventer_lk).unwrap();\n            }\n            *eventer_lk = false;\n            drop(eventer_lk);\n\n            loop {\n                let (act_name, event) =\n                    if let Some((act_name, event)) = self.event_queue.lock().unwrap().pop_front() {\n                        (act_name, event)\n                    } else {\n                        break;\n                    };\n                let token_event_cmd = if let Some(token_event_cmd) =\n                    pstate.ct_lock().config().token_event_cmd.clone()\n                {\n                    token_event_cmd\n                } else {\n                    break;\n                };\n                if let Err(e) = shell_cmd(\n                    &token_event_cmd,\n                    [\n                        (\"PIZAUTH_ACCOUNT\", act_name.as_str()),\n                        (\"PIZAUTH_EVENT\", &event.to_string()),\n                    ],\n                    TOKEN_EVENT_CMD_TIMEOUT,\n                ) {\n                    error!(\"{e}\");\n                }\n            }\n        });\n\n        Ok(())\n    }\n\n    pub fn token_event(&self, act_name: String, kind: TokenEvent) {\n        self.event_queue.lock().unwrap().push_back((act_name, kind));\n        let mut event_lk = self.pred.lock().unwrap();\n        *event_lk = true;\n        self.condvar.notify_one();\n    }\n}\n"
  },
  {
    "path": "src/server/http_server.rs",
    "content": "use std::{\n    error::Error,\n    io::{BufRead, BufReader, Read, Write},\n    net::TcpListener,\n    sync::Arc,\n    thread,\n    time::Duration,\n};\n\nuse boot_time::Instant;\nuse log::warn;\nuse serde_json::Value;\nuse url::Url;\n\nuse rcgen::{generate_simple_self_signed, CertifiedKey, KeyPair};\nuse rustls::{\n    pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},\n    ServerConfig,\n};\n\nuse super::{\n    eventer::TokenEvent, expiry_instant, AccountId, AuthenticatorState, Config, TokenState,\n    UREQ_TIMEOUT,\n};\n\n/// How often should we try making a request to an OAuth server for possibly-temporary transport\n/// issues?\nconst RETRY_POST: u8 = 10;\n/// How long to delay between each retry?\nconst RETRY_DELAY: u64 = 6;\n/// What is the maximum HTTP request size, in bytes, we allow? We are less worried about malicious\n/// actors than we are about malfunctioning systems. We thus set this to a far higher value than we\n/// actually expect to see in practise: if any client connecting exceeds this, they've probably got\n/// real problems!\nconst MAX_HTTP_REQUEST_SIZE: usize = 16 * 1024;\n\n/// Handle an incoming (hopefully OAuth2) HTTP request.\nfn request<T: Read + Write>(\n    pstate: Arc<AuthenticatorState>,\n    mut stream: T,\n    is_https: bool,\n) -> Result<(), Box<dyn Error>> {\n    // This function is split into two halves. In the first half, we process the incoming HTTP\n    // request: if there's a problem, it (mostly) means the request is mal-formed or stale, and\n    // there's no effect on the tokenstate. In the second half we make a request to an OAuth\n    // server: if there's a problem, we have to reset the tokenstate and force the user to make an\n    // entirely fresh request.\n    let uri = match parse_get(&mut stream, is_https) {\n        Ok(x) => x,\n        Err(_) => {\n            // If someone couldn't even be bothered giving us a valid URI, it's unlikely this was a\n            // genuine request that's worth reporting as an error.\n            http_400(stream);\n            return Ok(());\n        }\n    };\n\n    // All valid requests (even those reporting an error!) should report back a valid \"state\" to\n    // us, so fish that out of the URI and check that it matches a request we made.\n    let state = match uri.query_pairs().find(|(k, _)| k == \"state\") {\n        Some((_, state)) => state.into_owned(),\n        None => {\n            // As well as malformed OAuth queries this will also 404 for favicon.ico.\n            http_404(stream);\n            return Ok(());\n        }\n    };\n    let mut ct_lk = pstate.ct_lock();\n    let act_id = match ct_lk.act_id_matching_token_state(&state) {\n        Some(x) => x,\n        None => {\n            drop(ct_lk);\n            http_200(\n                stream,\n                \"No pending token matches request state: request a fresh token\",\n            );\n            return Ok(());\n        }\n    };\n\n    // Now that we know which account has been matched we can check if the full URI requested\n    // matched the redirect URI we expected for that account.\n    let act = ct_lk.account(act_id);\n    let expected_uri = act.redirect_uri(pstate.http_port, pstate.https_port)?;\n    if !redirect_uri_matches(&expected_uri, &uri) {\n        // If the redirect URI doesn't match then all we can do is 404.\n        drop(ct_lk);\n        http_404(stream);\n        return Ok(());\n    }\n\n    // Did authentication fail?\n    if let Some((_, reason)) = uri.query_pairs().find(|(k, _)| k == \"error\") {\n        let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n        let act_name = ct_lk.account(act_id).name.clone();\n        let msg = format!(\n            \"Authentication for {} failed: {}\",\n            ct_lk.account(act_id).name,\n            reason\n        );\n        drop(ct_lk);\n        http_400(stream);\n        pstate.notifier.notify_error(&pstate, act_name, msg)?;\n        return Ok(());\n    }\n\n    // Fish out the code query.\n    let code = match uri.query_pairs().find(|(k, _)| k == \"code\") {\n        Some((_, code)) => code.to_string(),\n        None => {\n            // A request without a 'code' is broken. This seems very unlikely to happen and if it\n            // does, would retrying our request from scratch improve anything?\n            drop(ct_lk);\n            http_400(stream);\n            return Ok(());\n        }\n    };\n\n    let code_verifier = match ct_lk.tokenstate(act_id) {\n        TokenState::Pending {\n            ref code_verifier, ..\n        } => code_verifier.clone(),\n        _ => unreachable!(),\n    };\n    let token_uri = act.token_uri.clone();\n    let client_id = act.client_id.clone();\n    let redirect_uri = act\n        .redirect_uri(pstate.http_port, pstate.https_port)?\n        .to_string();\n    let mut pairs = vec![\n        (\"code\", code.as_str()),\n        (\"client_id\", client_id.as_str()),\n        (\"code_verifier\", code_verifier.as_str()),\n        (\"redirect_uri\", redirect_uri.as_str()),\n        (\"grant_type\", \"authorization_code\"),\n    ];\n    let client_secret = act.client_secret.clone();\n    if let Some(ref x) = client_secret {\n        pairs.push((\"client_secret\", x));\n    }\n\n    // At this point we know we've got a sensible looking query, so we complete the HTTP request,\n    // because we don't know how long we'll spend going through the rest of the OAuth process, and\n    // we can notify the user another way than through their web browser.\n    drop(ct_lk);\n    http_200(\n        stream,\n        \"pizauth processing authentication: you can safely close this page.\",\n    );\n\n    // Try moderately hard to deal with temporary network errors and the like, but assume that any\n    // request that partially makes a connection but does not then fully succeed is an error (since\n    // we can't reuse authentication codes), and we'll have to start again entirely.\n    let mut body = None;\n    let agent_conf = ureq::Agent::config_builder()\n        .timeout_global(Some(UREQ_TIMEOUT))\n        .build();\n    for _ in 0..RETRY_POST {\n        match ureq::Agent::new_with_config(agent_conf.clone())\n            .post(token_uri.as_str())\n            .send_form(pairs.clone())\n        {\n            Ok(response) => {\n                if let Ok(s) = response.into_body().read_to_string() {\n                    body = Some(s);\n                    break;\n                }\n            }\n            Err(ureq::Error::StatusCode(code)) => {\n                let reason = format!(\"HTTP code {code}\");\n                fail(pstate, act_id, &reason)?;\n                return Ok(());\n            }\n            Err(_) => (), // Temporary network error or the like\n        }\n        thread::sleep(Duration::from_secs(RETRY_DELAY));\n    }\n    let body = match body {\n        Some(x) => x,\n        None => {\n            fail(pstate, act_id, &format!(\"couldn't connect to {token_uri:}\"))?;\n            return Ok(());\n        }\n    };\n\n    let parsed = match serde_json::from_str::<Value>(&body) {\n        Ok(x) => x,\n        Err(e) => {\n            fail(pstate, act_id, &format!(\"Invalid JSON: {e}\"))?;\n            return Ok(());\n        }\n    };\n\n    let mut ct_lk = pstate.ct_lock();\n    if !ct_lk.is_act_id_valid(act_id) {\n        return Ok(());\n    }\n\n    if let Some(err_msg) = parsed[\"error\"].as_str() {\n        drop(ct_lk);\n        fail(pstate, act_id, err_msg)?;\n        return Ok(());\n    }\n\n    match (\n        parsed[\"token_type\"].as_str(),\n        parsed[\"expires_in\"].as_u64(),\n        parsed[\"access_token\"].as_str(),\n        parsed[\"refresh_token\"].as_str(),\n    ) {\n        (Some(\"Bearer\"), Some(expires_in), Some(access_token), refresh_token) => {\n            let now = Instant::now();\n            let expiry = expiry_instant(&ct_lk, act_id, now, expires_in)?;\n            let act_name = ct_lk.account(act_id).name.to_owned();\n            ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Active {\n                    access_token: access_token.to_owned(),\n                    access_token_obtained: now,\n                    access_token_expiry: expiry,\n                    ongoing_refresh: false,\n                    consecutive_refresh_fails: 0,\n                    last_refresh_attempt: None,\n                    refresh_token: refresh_token.map(|x| x.to_owned()),\n                },\n            );\n            drop(ct_lk);\n            pstate.refresher.notify_changes();\n            pstate.eventer.token_event(act_name, TokenEvent::New);\n        }\n        _ => {\n            drop(ct_lk);\n            fail(pstate, act_id, \"invalid response received\")?;\n        }\n    }\n    Ok(())\n}\n\n/// If a request to an OAuth server has failed then notify the user of that failure and mark the\n/// tokenstate as [TokenState::Empty] unless the config has changed or the user has initiated a new\n/// request while we've been trying (unsuccessfully) with the OAuth server.\nfn fail(\n    pstate: Arc<AuthenticatorState>,\n    act_id: AccountId,\n    msg: &str,\n) -> Result<(), Box<dyn Error>> {\n    let mut ct_lk = pstate.ct_lock();\n    if ct_lk.is_act_id_valid(act_id) {\n        // It's possible -- though admittedly unlikely -- that another thread has managed to grab\n        // an `Active` token so we have to handle the possibility.\n        let is_active = matches!(ct_lk.tokenstate(act_id), TokenState::Active { .. });\n        let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n        let act_name = ct_lk.account(act_id).name.clone();\n        let msg = format!(\n            \"Authentication for {} failed: {msg:}\",\n            ct_lk.account(act_id).name\n        );\n        drop(ct_lk);\n        pstate\n            .notifier\n            .notify_error(&pstate, act_name.clone(), msg)?;\n        if is_active {\n            pstate\n                .eventer\n                .token_event(act_name, TokenEvent::Invalidated);\n        }\n    }\n    Ok(())\n}\n\n/// A very literal, and rather unforgiving, implementation of RFC2616 (HTTP/1.1), returning the URL\n/// of GET requests: returns `Err` for anything else.\nfn parse_get<T: Read + Write>(stream: &mut T, is_https: bool) -> Result<Url, Box<dyn Error>> {\n    let mut rdr = BufReader::new(stream);\n    let mut req_line = String::new();\n    rdr.read_line(&mut req_line)?;\n    let mut http_req_size = req_line.len();\n\n    // First the request line:\n    //  Request-Line   = Method SP Request-URI SP HTTP-Version CRLF\n    // where Method = \"GET\" and `SP` is a single space character.\n    let req_line_sp = req_line.split(' ').collect::<Vec<_>>();\n    if !matches!(req_line_sp.as_slice(), &[\"GET\", _, _]) {\n        return Err(\"Malformed HTTP request\".into());\n    }\n    let path = req_line_sp[1];\n\n    // Consume rest of HTTP request\n    let mut req: Vec<String> = Vec::new();\n    loop {\n        if http_req_size >= MAX_HTTP_REQUEST_SIZE {\n            return Err(\"HTTP request exceeds maximum permitted size\".into());\n        }\n        let mut line = String::new();\n        rdr.read_line(&mut line)?;\n        if line.as_str().trim().is_empty() {\n            break;\n        }\n        http_req_size += line.len();\n        match line.chars().next() {\n            Some(' ') | Some('\\t') => {\n                // Continuation of previous header\n                match req.last_mut() {\n                    Some(x) => {\n                        // Not calling `trim_start` means that the two joined lines have at least\n                        // one space|tab between them.\n                        x.push_str(line.as_str().trim_end());\n                    }\n                    None => return Err(\"Malformed HTTP header\".into()),\n                }\n            }\n            _ => req.push(line.as_str().trim_end().to_owned()),\n        }\n    }\n\n    // Find the host field.\n    let mut host = None;\n    for f in req {\n        // Fields are a case insensitive name, followed by a colon, then zero or more tabs/spaces,\n        // and then the value.\n        if let Some(i) = f.as_str().find(':') {\n            if f.as_str()[..i].eq_ignore_ascii_case(\"host\") {\n                if host.is_some() {\n                    // Fields can be repeated, but that doesn't make sense for \"host\"\n                    return Err(\"Repeated 'host' field in HTTP header\".into());\n                }\n                let j: usize = f[i + ':'.len_utf8()..]\n                    .chars()\n                    .take_while(|c| *c == ' ' || *c == '\\t')\n                    .map(|c| c.len_utf8())\n                    .sum();\n                host = Some(f[i + ':'.len_utf8() + j..].to_string());\n            }\n        }\n    }\n\n    // If host is Some, use addressed port to select scheme (http / https)\n    // This works, as no HTTPS request will arrive until here on the HTTP port and vice versa\n    match host {\n        Some(h) => Url::parse(&format!(\n            \"{}://{h:}{path:}\",\n            if is_https { \"https\" } else { \"http\" }\n        ))\n        .map_err(|e| format!(\"Invalid request URI: {e:}\").into()),\n        None => Err(\"No host field specified in HTTP request\".into()),\n    }\n}\n\nfn http_200<T: Read + Write>(mut stream: T, body: &str) {\n    stream\n        .write_all(\n            format!(\"HTTP/1.1 200 OK\\r\\n\\r\\n<html><body><h2>{body}</h2></body></html>\").as_bytes(),\n        )\n        .ok();\n}\n\nfn http_404<T: Read + Write>(mut stream: T) {\n    stream.write_all(b\"HTTP/1.1 404\\r\\n\\r\\n\").ok();\n}\n\nfn http_400<T: Read + Write>(mut stream: T) {\n    stream.write_all(b\"HTTP/1.1 400\\r\\n\\r\\n\").ok();\n}\n\n/// Return `true` if `actual` matches `expected` or `false` otherwise.\nfn redirect_uri_matches(expected: &Url, actual: &Url) -> bool {\n    assert!(expected.fragment().is_none());\n    if expected.scheme() != actual.scheme()\n        || expected.host_str() != actual.host_str()\n        || expected.port() != actual.port()\n        || expected.path() != actual.path()\n        || actual.fragment().is_some()\n    {\n        return false;\n    }\n\n    let actual_pairs = actual.query_pairs().collect::<Vec<_>>();\n    for x in expected.query_pairs() {\n        if !actual_pairs.contains(&x) {\n            return false;\n        }\n    }\n\n    true\n}\n\npub fn http_server_setup(conf: &Config) -> Result<Option<(u16, TcpListener)>, Box<dyn Error>> {\n    // Bind TCP port for HTTP\n    match &conf.http_listen {\n        Some(http_listen) => {\n            let listener = TcpListener::bind(http_listen)?;\n            Ok(Some((listener.local_addr()?.port(), listener)))\n        }\n        None => Ok(None),\n    }\n}\n\npub fn http_server(\n    pstate: Arc<AuthenticatorState>,\n    listener: TcpListener,\n) -> Result<(), Box<dyn Error>> {\n    thread::spawn(move || {\n        for stream in listener.incoming().flatten() {\n            let pstate = Arc::clone(&pstate);\n            thread::spawn(|| {\n                if let Err(e) = request(pstate, stream, false) {\n                    warn!(\"{e:}\");\n                }\n            });\n        }\n    });\n    Ok(())\n}\n\npub fn https_server_setup(\n    conf: &Config,\n) -> Result<Option<(u16, TcpListener, CertifiedKey<KeyPair>)>, Box<dyn Error>> {\n    match &conf.https_listen {\n        Some(https_listen) => {\n            // Set a process wide default crypto provider.\n            let _ = rustls::crypto::ring::default_provider().install_default();\n\n            // Generate self-signed certificate\n            let mut names = vec![\n                String::from(\"localhost\"),\n                String::from(\"127.0.0.1\"),\n                String::from(\"::1\"),\n            ];\n            if let Ok(x) = hostname::get() {\n                if let Some(x) = x.to_str() {\n                    names.push(String::from(x));\n                }\n            }\n            let cert = generate_simple_self_signed(names)?;\n\n            // Bind TCP port for HTTPS\n            let listener = TcpListener::bind(https_listen)?;\n            Ok(Some((listener.local_addr()?.port(), listener, cert)))\n        }\n        None => Ok(None),\n    }\n}\n\npub fn https_server(\n    pstate: Arc<AuthenticatorState>,\n    listener: TcpListener,\n    cert: CertifiedKey<KeyPair>,\n) -> Result<(), Box<dyn Error>> {\n    // Build TLS configuration.\n    let mut server_config = ServerConfig::builder()\n        .with_no_client_auth()\n        .with_single_cert(\n            vec![cert.cert.into()],\n            PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())),\n        )\n        .map_err(|e| e.to_string())?;\n\n    // Negotiate application layer protocols: Only HTTP/1.1 is allowed\n    server_config.alpn_protocols = vec![b\"http/1.1\".to_vec()];\n\n    thread::spawn(move || {\n        for mut stream in listener.incoming().flatten() {\n            // generate a new TLS connection\n            let conn = rustls::ServerConnection::new(Arc::new(server_config.clone()));\n            if let Err(e) = conn {\n                warn!(\"{e:}\");\n                continue;\n            }\n            let mut conn = conn.unwrap();\n\n            let pstate = Arc::clone(&pstate);\n            thread::spawn(move || {\n                // convert TCP stream into TLS stream\n                let stream = rustls::Stream::new(&mut conn, &mut stream);\n                if let Err(e) = request(pstate, stream, true) {\n                    warn!(\"{e:}\");\n                }\n            });\n        }\n    });\n    Ok(())\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n\n    #[test]\n    fn redirect_uri_matching() {\n        fn t(expected: &str, actual: &str) -> bool {\n            redirect_uri_matches(&Url::parse(expected).unwrap(), &Url::parse(actual).unwrap())\n        }\n\n        assert!(t(\"http://a.com/b\", \"http://a.com/b\"));\n        assert!(!t(\"http://a.com/b\", \"http://b.com/b\"));\n        assert!(!t(\"http://a.com/b\", \"http://a.com/c\"));\n\n        assert!(t(\"http://a.com:1234/b\", \"http://a.com:1234/b\"));\n        assert!(!t(\"http://a.com:1234/b\", \"http://a.com:123/b\"));\n\n        assert!(t(\"http://a.com/b?c=d\", \"http://a.com/b?c=d\"));\n        assert!(!t(\"http://a.com:1234/b?c=d\", \"http://a.com:1234/b\"));\n\n        assert!(t(\"http://a.com/b\", \"http://a.com/b?c=d\"));\n\n        assert!(!t(\"http://a.com/b\", \"http://a.com/b#c\"));\n    }\n}\n"
  },
  {
    "path": "src/server/mod.rs",
    "content": "mod eventer;\nmod http_server;\nmod notifier;\nmod refresher;\nmod request_token;\nmod state;\n\nuse std::{\n    collections::HashMap,\n    env,\n    error::Error,\n    io::{Read, Write},\n    os::unix::net::{UnixListener, UnixStream},\n    path::{Path, PathBuf},\n    process::Command,\n    sync::Arc,\n    thread,\n    time::{Duration, SystemTime},\n};\n\nuse boot_time::Instant;\nuse chrono::{DateTime, Local};\nuse log::{error, warn};\nuse nix::sys::signal::{raise, Signal};\n#[cfg(target_os = \"openbsd\")]\nuse pledge::pledge;\n#[cfg(target_os = \"openbsd\")]\nuse unveil::unveil;\n\nuse crate::{config::Config, PIZAUTH_CACHE_SOCK_LEAF};\nuse eventer::{Eventer, TokenEvent};\nuse notifier::Notifier;\nuse refresher::Refresher;\nuse request_token::request_token;\nuse serde_json::json;\nuse state::{AccountId, AuthenticatorState, CTGuard, TokenState};\n\n/// Length of the PKCE code verifier in bytes.\nconst CODE_VERIFIER_LEN: usize = 64;\n/// The timeout for ureq HTTP requests. It is recommended to make this value lower than\n/// REFRESH_RETRY_DEFAULT to reduce the likelihood that refresh requests overlap.\npub const UREQ_TIMEOUT: Duration = Duration::from_secs(30);\n/// Length of the OAuth \"state\" in bytes: this is a string we send when requesting a token that is\n/// echoed back to us, allowing us to distinguish different request. There's no fixed size for\n/// this, and indeed one can go perhaps up to at least a kilobyte, but that's probably not going to\n/// give us useful additional security, and it makes request URLs even longer.\nconst STATE_LEN: usize = 32;\n/// When waiting to do something (e.g. in the notifier or refresher), we have the problem that when\n/// we ask to be woken up in \"X seconds from now\", operating systems do not interpret that as \"wake\n/// you up in X seconds of wall-clock time\". For example, if a machine is suspended then resumed,\n/// then the time the machine was out of action may not be counted as \"wait time\". The impact of\n/// ntp/adjtime and friends is also unclear. There is no portable way for us to know if any of\n/// these things has happened, so we are left in the unhappy situation that if a thread knows it\n/// has work to do in the future, it needs to wake itself up every so often to check if -- without\n/// us knowing it! -- the clock has changed underneath it.\n///\n/// There is no universally good value here. Too short means that we waste resources; too long and\n/// the user will think that we have gone wrong; too predictable and we might end up causing weird\n/// spikes in performance (e.g. if we wake up exactly every 10/30/60 seconds). To make problems\n/// even less likely, we choose a prime number.\nconst MAX_WAIT_SECS: u64 = 37;\n\npub fn sock_path(cache_path: &Path) -> PathBuf {\n    let mut p = cache_path.to_owned();\n    p.push(PIZAUTH_CACHE_SOCK_LEAF);\n    p\n}\n\n/// Calculate the [Instant] that a token will expire at. Returns `Err` if [Instant] cannot\n/// represent the expiry.\npub fn expiry_instant(\n    ct_lk: &CTGuard,\n    act_id: AccountId,\n    refreshed_at: Instant,\n    expires_in: u64,\n) -> Result<Instant, Box<dyn Error>> {\n    refreshed_at\n        .checked_add(Duration::from_secs(expires_in))\n        .or_else(|| {\n            refreshed_at.checked_add(ct_lk.account(act_id).refresh_at_least(ct_lk.config()))\n        })\n        .ok_or_else(|| \"Can't represent expiry\".into())\n}\n\nfn request(pstate: Arc<AuthenticatorState>, mut stream: UnixStream) -> Result<(), Box<dyn Error>> {\n    let mut buf = Vec::new();\n    stream.read_to_end(&mut buf)?;\n    let (cmd, rest) = {\n        let len = buf\n            .iter()\n            .map(|b| *b as char)\n            .take_while(|c| *c != ':')\n            .count();\n        if len == buf.len() {\n            return Err(format!(\n                \"Syntactically invalid request '{}'\",\n                std::str::from_utf8(&buf).unwrap_or(\"<can't represent as UTF-8\")\n            )\n            .into());\n        }\n        (std::str::from_utf8(&buf[..len])?, &buf[len + 1..])\n    };\n\n    match cmd {\n        \"dump\" if rest.is_empty() => {\n            stream.write_all(&pstate.dump()?)?;\n            return Ok(());\n        }\n        \"info\" if rest.is_empty() => {\n            let mut m = HashMap::new();\n            m.insert(\n                \"http_port\",\n                match pstate.http_port {\n                    Some(x) => x.to_string(),\n                    None => \"none\".to_string(),\n                },\n            );\n            m.insert(\n                \"https_port\",\n                match pstate.https_port {\n                    Some(x) => x.to_string(),\n                    None => \"none\".to_string(),\n                },\n            );\n            if let Some(x) = &pstate.https_pub_key {\n                m.insert(\"https_pub_key\", x.clone());\n            }\n            stream.write_all(json!(m).to_string().as_bytes())?;\n            return Ok(());\n        }\n        \"reload\" if rest.is_empty() => {\n            match Config::from_path(&pstate.conf_path) {\n                Ok(new_conf) => {\n                    pstate.update_conf(new_conf);\n                    stream.write_all(b\"ok:\")?\n                }\n                Err(e) => stream.write_all(format!(\"error:{e:}\").as_bytes())?,\n            }\n            return Ok(());\n        }\n        \"refresh\" => {\n            let rest = std::str::from_utf8(rest)?;\n            if let [with_url, act_name] = &rest.splitn(2, ' ').collect::<Vec<_>>()[..] {\n                let ct_lk = pstate.ct_lock();\n                let act_id = match ct_lk.validate_act_name(act_name) {\n                    Some(x) => x,\n                    None => {\n                        drop(ct_lk);\n                        stream.write_all(format!(\"error:No account '{act_name:}'\").as_bytes())?;\n                        return Ok(());\n                    }\n                };\n                match ct_lk.tokenstate(act_id) {\n                    TokenState::Empty | TokenState::Pending { .. } => {\n                        let url = request_token(Arc::clone(&pstate), ct_lk, act_id)?;\n                        if *with_url == \"withurl\" {\n                            stream.write_all(format!(\"pending:{url:}\").as_bytes())?;\n                        } else {\n                            stream.write_all(b\"pending:\")?;\n                        }\n                    }\n                    TokenState::Active { .. } => {\n                        drop(ct_lk);\n                        pstate.refresher.sched_refresh(Arc::clone(&pstate), act_id);\n                        stream.write_all(b\"scheduled:\")?;\n                    }\n                }\n                return Ok(());\n            }\n        }\n        \"restore\" => {\n            match pstate.restore(rest.to_vec()) {\n                Ok(_) => stream.write_all(b\"ok:\")?,\n                Err(e) => stream.write_all(format!(\"error:{e:}\").as_bytes())?,\n            }\n            return Ok(());\n        }\n        \"revoke\" => {\n            let act_name = std::str::from_utf8(rest)?;\n            let mut ct_lk = pstate.ct_lock();\n            match ct_lk.validate_act_name(act_name) {\n                Some(act_id) => {\n                    ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                    drop(ct_lk);\n\n                    pstate\n                        .eventer\n                        .token_event(act_name.to_owned(), TokenEvent::Revoked);\n                    stream.write_all(b\"ok:\")?;\n                    return Ok(());\n                }\n                None => {\n                    drop(ct_lk);\n                    stream.write_all(format!(\"error:No account '{act_name:}'\").as_bytes())?;\n                    return Ok(());\n                }\n            };\n        }\n        \"showtoken\" => {\n            let rest = std::str::from_utf8(rest)?;\n            if let [with_url, act_name] = &rest.splitn(2, ' ').collect::<Vec<_>>()[..] {\n                let ct_lk = pstate.ct_lock();\n                let act_id = match ct_lk.validate_act_name(act_name) {\n                    Some(x) => x,\n                    None => {\n                        drop(ct_lk);\n                        stream.write_all(format!(\"error:No account '{act_name:}'\").as_bytes())?;\n                        return Ok(());\n                    }\n                };\n                match ct_lk.tokenstate(act_id) {\n                    TokenState::Empty => {\n                        let url = request_token(Arc::clone(&pstate), ct_lk, act_id)?;\n                        if *with_url == \"withurl\" {\n                            stream.write_all(format!(\"pending:{url:}\").as_bytes())?;\n                        } else {\n                            stream.write_all(b\"pending:\")?;\n                        }\n                    }\n                    TokenState::Pending { ref url, .. } => {\n                        let response = if *with_url == \"withurl\" {\n                            format!(\"pending:{url:}\")\n                        } else {\n                            \"pending:\".to_owned()\n                        };\n                        drop(ct_lk);\n                        stream.write_all(response.as_bytes())?;\n                    }\n                    TokenState::Active {\n                        access_token,\n                        access_token_expiry,\n                        ongoing_refresh,\n                        ..\n                    } => {\n                        let response = if access_token_expiry > &Instant::now() {\n                            format!(\"access_token:{access_token:}\")\n                        } else if *ongoing_refresh {\n                            \"error:Access token has expired. Refreshing is in progress but has not yet succeeded\"\n                                .into()\n                        } else {\n                            pstate.refresher.sched_refresh(Arc::clone(&pstate), act_id);\n                            \"error:Access token has expired. Refreshing initiated\".into()\n                        };\n                        drop(ct_lk);\n                        stream.write_all(response.as_bytes())?;\n                    }\n                }\n                return Ok(());\n            }\n        }\n        \"shutdown\" if rest.is_empty() => {\n            raise(Signal::SIGTERM).ok();\n            return Ok(());\n        }\n        \"status\" if rest.is_empty() => {\n            let ct_lk = pstate.ct_lock();\n            let mut acts = Vec::new();\n            for act_id in ct_lk.act_ids() {\n                let act = ct_lk.account(act_id);\n                let st = match ct_lk.tokenstate(act_id) {\n                    TokenState::Empty => \"No access token\".into(),\n                    TokenState::Pending {\n                        last_notification: Some(i),\n                        ..\n                    } => format!(\n                        \"Access token pending authentication (last notification {})\",\n                        instant_fmt(*i)\n                    ),\n                    TokenState::Pending {\n                        last_notification: None,\n                        ..\n                    } => \"Access token pending authentication\".into(),\n                    TokenState::Active {\n                        access_token_obtained,\n                        access_token_expiry,\n                        last_refresh_attempt,\n                        ..\n                    } => {\n                        if *access_token_expiry > Instant::now() {\n                            format!(\n                                \"Active access token (obtained {}; expires {})\",\n                                instant_fmt(*access_token_obtained),\n                                instant_fmt(*access_token_expiry)\n                            )\n                        } else if let Some(i) = last_refresh_attempt {\n                            format!(\n                                \"Access token expired (last refresh attempt {})\",\n                                instant_fmt(*i)\n                            )\n                        } else {\n                            \"Access token expired (refresh not yet attempted)\".into()\n                        }\n                    }\n                };\n                acts.push(format!(\"{}: {st}\", act.name));\n            }\n            acts.sort();\n            if acts.is_empty() {\n                stream.write_all(b\"error:No accounts configured\")?;\n            } else {\n                stream.write_all(format!(\"ok:{}\", acts.join(\"\\n\")).as_bytes())?;\n            }\n            return Ok(());\n        }\n        x => stream.write_all(format!(\"error:Unknown command '{x}'\").as_bytes())?,\n    }\n    Err(\"Invalid command\".into())\n}\n\n/// Attempt to print an [Instant] as a user-readable string. By the very nature of [Instant]s,\n/// there is no guarantee this is possible or that the time presented is accurate.\nfn instant_fmt(i: Instant) -> String {\n    let now = Instant::now();\n    if i < now {\n        if let Some(d) = now.checked_duration_since(i) {\n            if let Some(st) = SystemTime::now().checked_sub(d) {\n                let dt: DateTime<Local> = st.into();\n                return dt.to_rfc2822();\n            }\n        }\n    } else if let Some(d) = i.checked_duration_since(now) {\n        if let Some(st) = SystemTime::now().checked_add(d) {\n            let dt: DateTime<Local> = st.into();\n            return dt.to_rfc2822();\n        }\n    }\n    \"<unknown time>\".into()\n}\n\n/// If [Config::startup_cmd] is non-`None`, call this function to run that command (in a thread, so\n/// this is non-blocking).\nfn startup_cmd(cmd: String) {\n    thread::spawn(move || match env::var(\"SHELL\") {\n        Ok(s) => match Command::new(s).args([\"-c\", &cmd]).spawn() {\n            Ok(mut child) => match child.wait() {\n                Ok(status) => {\n                    if !status.success() {\n                        error!(\n                            \"'{cmd:}' returned {}\",\n                            status\n                                .code()\n                                .map(|x| x.to_string())\n                                .unwrap_or_else(|| \"<Unknown exit code\".to_string())\n                        );\n                    }\n                }\n                Err(e) => error!(\"Waiting on '{cmd:}' failed: {e:}\"),\n            },\n            Err(e) => error!(\"Couldn't execute '{cmd:}': {e:}\"),\n        },\n        Err(e) => error!(\"{e:}\"),\n    });\n}\n\npub fn server(conf_path: PathBuf, conf: Config, cache_path: &Path) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n\n    #[cfg(target_os = \"openbsd\")]\n    unveil(\n        conf_path\n            .as_os_str()\n            .to_str()\n            .ok_or(\"Cannot use configuration path in unveil\")?,\n        \"rx\",\n    )?;\n    #[cfg(target_os = \"openbsd\")]\n    unveil(\n        sock_path\n            .as_os_str()\n            .to_str()\n            .ok_or(\"Cannot use socket path in unveil\")?,\n        \"rwxc\",\n    )?;\n    #[cfg(target_os = \"openbsd\")]\n    unveil(std::env::var(\"SHELL\")?, \"rx\")?;\n    #[cfg(target_os = \"openbsd\")]\n    unveil(\"/dev/random\", \"rx\")?;\n    #[cfg(target_os = \"openbsd\")]\n    unveil(\"\", \"\")?;\n\n    #[cfg(target_os = \"openbsd\")]\n    pledge(\"stdio rpath wpath inet fattr unix dns proc exec\", None).unwrap();\n\n    let (http_port, http_state) = match http_server::http_server_setup(&conf)? {\n        Some((x, y)) => (Some(x), Some(y)),\n        None => (None, None),\n    };\n    let (https_port, https_state, certified_key) = match http_server::https_server_setup(&conf)? {\n        Some((x, y, z)) => (Some(x), Some(y), Some(z)),\n        None => (None, None, None),\n    };\n    // TODO: Store certificate into trusted folder (OS dependent..)?\n\n    let eventer = Arc::new(Eventer::new()?);\n    let notifier = Arc::new(Notifier::new()?);\n    let refresher = Refresher::new();\n\n    let pub_key_str = certified_key.as_ref().map(|x| {\n        x.signing_key\n            .public_key_raw()\n            .iter()\n            .map(|x| format!(\"{x:02X}\"))\n            .collect::<Vec<_>>()\n            .join(\":\")\n    });\n\n    let pstate = Arc::new(AuthenticatorState::new(\n        conf_path,\n        conf,\n        http_port,\n        https_port,\n        pub_key_str,\n        Arc::clone(&eventer),\n        Arc::clone(&notifier),\n        Arc::clone(&refresher),\n    ));\n\n    if let Some(x) = http_state {\n        http_server::http_server(Arc::clone(&pstate), x)?;\n    }\n    if let (Some(x), Some(y)) = (https_state, certified_key) {\n        http_server::https_server(Arc::clone(&pstate), x, y)?;\n    }\n    eventer.eventer(Arc::clone(&pstate))?;\n    refresher.refresher(Arc::clone(&pstate))?;\n    notifier.notifier(Arc::clone(&pstate))?;\n\n    let listener = UnixListener::bind(sock_path)?;\n    if let Some(s) = &pstate.ct_lock().config().startup_cmd {\n        startup_cmd(s.to_owned());\n    }\n    for stream in listener.incoming().flatten() {\n        let pstate = Arc::clone(&pstate);\n        if let Err(e) = request(pstate, stream) {\n            warn!(\"{e:}\");\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/server/notifier.rs",
    "content": "use std::{\n    cmp,\n    error::Error,\n    sync::{Arc, Condvar, Mutex},\n    thread,\n    time::Duration,\n};\n\nuse boot_time::Instant;\n#[cfg(debug_assertions)]\nuse log::debug;\nuse log::error;\n\nuse crate::{\n    server::{AccountId, AuthenticatorState, CTGuard, TokenState, MAX_WAIT_SECS},\n    shell_cmd::shell_cmd,\n};\n\n/// How long to run `auth_notify_cmd`s before killing them?\nconst AUTH_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);\n/// How long to run `error_notify_cmd`s before killing them?\nconst ERROR_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);\n\npub struct Notifier {\n    pred: Mutex<bool>,\n    condvar: Condvar,\n}\n\nimpl Notifier {\n    pub fn new() -> Result<Notifier, Box<dyn Error>> {\n        Ok(Notifier {\n            pred: Mutex::new(false),\n            condvar: Condvar::new(),\n        })\n    }\n\n    pub fn notifier(\n        self: Arc<Self>,\n        pstate: Arc<AuthenticatorState>,\n    ) -> Result<(), Box<dyn Error>> {\n        thread::spawn(move || loop {\n            let next_wakeup = self.next_wakeup(&pstate);\n            let mut notify_lk = self.pred.lock().unwrap();\n            while !*notify_lk {\n                match next_wakeup {\n                    Some(t) => match t.checked_duration_since(Instant::now()) {\n                        Some(d) => {\n                            #[cfg(debug_assertions)]\n                            debug!(\"Notifier: next wakeup {}\", d.as_secs());\n                            notify_lk = self.condvar.wait_timeout(notify_lk, d).unwrap().0\n                        }\n                        None => break,\n                    },\n                    None => {\n                        #[cfg(debug_assertions)]\n                        debug!(\"Notifier: next wakeup <indefinite>\");\n                        notify_lk = self.condvar.wait(notify_lk).unwrap();\n                    }\n                }\n            }\n            *notify_lk = false;\n            drop(notify_lk);\n\n            let mut auth_cmds = Vec::new();\n            let mut ct_lk = pstate.ct_lock();\n            let now = Instant::now();\n            let notify_interval = ct_lk.config().auth_notify_interval; // Pulled out to avoid borrow checker problems.\n            for act_id in ct_lk.act_ids().collect::<Vec<_>>() {\n                let mut ts = ct_lk.tokenstate(act_id).clone();\n                if let TokenState::Pending {\n                    ref mut last_notification,\n                    ref url,\n                    ..\n                } = ts\n                {\n                    if let Some(t) = last_notification {\n                        if let Some(t) = t.checked_add(notify_interval) {\n                            if t > now {\n                                continue;\n                            }\n                        }\n                    }\n                    *last_notification = Some(now);\n                    let url = url.clone();\n                    let act = ct_lk.account(act_id);\n                    if let Some(ref cmd) = ct_lk.config().auth_notify_cmd {\n                        auth_cmds.push((act.name.to_owned(), cmd.clone(), url));\n                    }\n                    ct_lk.tokenstate_replace(act_id, ts);\n                }\n            }\n            drop(ct_lk);\n\n            for (act_name, cmd, url) in auth_cmds.into_iter() {\n                if let Err(e) = shell_cmd(\n                    &cmd,\n                    [\n                        (\"PIZAUTH_ACCOUNT\", act_name.as_str()),\n                        (\"PIZAUTH_URL\", url.as_str()),\n                    ],\n                    AUTH_NOTIFY_CMD_TIMEOUT,\n                ) {\n                    error!(\"{e}\")\n                }\n            }\n        });\n\n        Ok(())\n    }\n\n    pub fn notify_changes(&self) {\n        let mut notify_lk = self.pred.lock().unwrap();\n        *notify_lk = true;\n        self.condvar.notify_one();\n    }\n\n    fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {\n        let ct_lk = pstate.ct_lock();\n        ct_lk\n            .act_ids()\n            .filter_map(|act_id| notify_at(pstate, &ct_lk, act_id))\n            .min()\n            .map(\n                |act_min| match Instant::now().checked_add(Duration::from_secs(MAX_WAIT_SECS)) {\n                    Some(x) => cmp::min(act_min, x),\n                    None => act_min,\n                },\n            )\n    }\n\n    pub fn notify_error(\n        &self,\n        pstate: &AuthenticatorState,\n        act_name: String,\n        msg: String,\n    ) -> Result<(), Box<dyn std::error::Error>> {\n        let cmd = {\n            let ct_lk = pstate.ct_lock();\n            ct_lk.config().error_notify_cmd.clone()\n        };\n        if let Some(cmd) = cmd {\n            if let Err(e) = shell_cmd(\n                &cmd,\n                [\n                    (\"PIZAUTH_ACCOUNT\", act_name.as_str()),\n                    (\"PIZAUTH_MSG\", &msg),\n                ],\n                ERROR_NOTIFY_CMD_TIMEOUT,\n            ) {\n                error!(\"{e}\")\n            }\n        }\n        Ok(())\n    }\n}\n\n/// If `act_id` has a pending token, return the next time when that user should be notified that\n/// it is pending.\nfn notify_at(_pstate: &AuthenticatorState, ct_lk: &CTGuard, act_id: AccountId) -> Option<Instant> {\n    match ct_lk.tokenstate(act_id) {\n        TokenState::Pending {\n            last_notification, ..\n        } => {\n            match last_notification {\n                None => Some(Instant::now()),\n                Some(t) => {\n                    // There is no concept of Instant::MAX, so if `refreshed_at + d` exceeds\n                    // Instant's bounds, there's nothing we can fall back on.\n                    t.checked_add(ct_lk.config().auth_notify_interval)\n                }\n            }\n        }\n        _ => None,\n    }\n}\n"
  },
  {
    "path": "src/server/refresher.rs",
    "content": "use std::{\n    cmp,\n    collections::HashSet,\n    error::Error,\n    sync::{Arc, Condvar, Mutex},\n    thread,\n    time::Duration,\n};\n\nuse boot_time::Instant;\n#[cfg(debug_assertions)]\nuse log::debug;\nuse log::{error, info};\nuse serde_json::Value;\n\nuse crate::{\n    server::{\n        eventer::TokenEvent, expiry_instant, AccountId, AuthenticatorState, CTGuard, TokenState,\n        MAX_WAIT_SECS, UREQ_TIMEOUT,\n    },\n    shell_cmd::shell_cmd,\n};\n\n/// How many times can a transient error be encountered before we try `not_transient_error_if`?\nconst TRANSIENT_ERROR_RETRIES: u64 = 6;\n/// How long to run `transient_error_if_cmd` commands before killing them?\nconst TRANSIENT_ERROR_IF_CMD_TIMEOUT: Duration = Duration::from_secs(3 * 60);\n\n/// The outcome of an attempted refresh.\n#[derive(Debug)]\nenum RefreshKind {\n    /// Refreshing terminated because the config or tokenstate changed.\n    AccountOrTokenStateChanged,\n    /// There is no refresh token so refreshing cannot succeed.\n    NoRefreshToken,\n    /// Refreshing failed in a way that is likely to repeat if retried.\n    PermanentError(String),\n    /// The token was refreshed.\n    Refreshed,\n    /// Refreshing failed but in a way that is not likely to repeat if retried.\n    TransitoryError(AccountId, String),\n}\n\npub struct Refresher {\n    pred: Mutex<bool>,\n    condvar: Condvar,\n}\n\nimpl Refresher {\n    pub fn new() -> Arc<Self> {\n        Arc::new(Refresher {\n            pred: Mutex::new(false),\n            condvar: Condvar::new(),\n        })\n    }\n\n    pub fn sched_refresh(self: &Arc<Self>, pstate: Arc<AuthenticatorState>, act_id: AccountId) {\n        let refresher = Arc::clone(self);\n        thread::spawn(move || {\n            let mut ct_lk = pstate.ct_lock();\n            if ct_lk.is_act_id_valid(act_id) {\n                let mut new_ts = ct_lk.tokenstate(act_id).clone();\n                if let TokenState::Active {\n                    ref mut ongoing_refresh,\n                    ..\n                } = new_ts\n                {\n                    if !*ongoing_refresh {\n                        *ongoing_refresh = true;\n                        let act_id = ct_lk.tokenstate_replace(act_id, new_ts);\n                        let act_name = ct_lk.account(act_id).name.clone();\n                        match refresher.inner_refresh(&pstate, ct_lk, act_id) {\n                            RefreshKind::AccountOrTokenStateChanged => (),\n                            RefreshKind::NoRefreshToken => (),\n                            RefreshKind::PermanentError(msg) => {\n                                info!(\"Permanent refresh error for {act_name}: {msg}\");\n                                pstate\n                                    .eventer\n                                    .token_event(act_name, TokenEvent::Invalidated);\n                            }\n                            RefreshKind::Refreshed => {\n                                refresher.notify_changes();\n                                pstate.eventer.token_event(act_name, TokenEvent::Refresh);\n                            }\n                            RefreshKind::TransitoryError(act_id, msg) => {\n                                ct_lk = pstate.ct_lock();\n                                if ct_lk.is_act_id_valid(act_id) {\n                                    let mut new_ts = ct_lk.tokenstate(act_id).clone();\n                                    if let TokenState::Active {\n                                        ref mut last_refresh_attempt,\n                                        ref mut consecutive_refresh_fails,\n                                        ..\n                                    } = new_ts\n                                    {\n                                        *last_refresh_attempt = Some(Instant::now());\n                                        *consecutive_refresh_fails += 1;\n                                        let consecutive_refresh_fails = *consecutive_refresh_fails;\n                                        let act_id = ct_lk.tokenstate_replace(act_id, new_ts);\n                                        if consecutive_refresh_fails\n                                            .rem_euclid(TRANSIENT_ERROR_RETRIES)\n                                            == 0\n                                        {\n                                            if let Some(ref cmd) =\n                                                ct_lk.config().transient_error_if_cmd\n                                            {\n                                                let cmd = cmd.to_owned();\n                                                drop(ct_lk);\n                                                match shell_cmd(\n                                                    &cmd,\n                                                    [(\"PIZAUTH_ACCOUNT\", act_name.as_str())],\n                                                    TRANSIENT_ERROR_IF_CMD_TIMEOUT,\n                                                ) {\n                                                    Ok(()) => {\n                                                        ct_lk = pstate.ct_lock();\n                                                        if ct_lk.is_act_id_valid(act_id) {\n                                                            ct_lk.tokenstate_set_ongoing_refresh(\n                                                                act_id, false,\n                                                            );\n                                                        }\n                                                        drop(ct_lk);\n                                                    }\n                                                    Err(e) => {\n                                                        ct_lk = pstate.ct_lock();\n                                                        if ct_lk.is_act_id_valid(act_id) {\n                                                            ct_lk.tokenstate_replace(\n                                                                act_id,\n                                                                TokenState::Empty,\n                                                            );\n                                                        }\n                                                        drop(ct_lk);\n                                                        error!(\"Permanent refresh error for {act_name}: {e}\");\n                                                        pstate.eventer.token_event(\n                                                            act_name,\n                                                            TokenEvent::Invalidated,\n                                                        );\n                                                    }\n                                                };\n                                            } else {\n                                                ct_lk.tokenstate_set_ongoing_refresh(act_id, false);\n                                                drop(ct_lk);\n                                                info!(\"Transitory refresh error for {act_name}: {msg}\");\n                                            }\n                                        } else {\n                                            ct_lk.tokenstate_set_ongoing_refresh(act_id, false);\n                                            drop(ct_lk);\n                                            info!(\"Transitory refresh error for {act_name}: {msg}\");\n                                        }\n                                    } else {\n                                        unreachable!();\n                                    }\n                                } else {\n                                    drop(ct_lk);\n                                }\n                                // If the main refresher thread noticed we were running it\n                                // might have given up, so give it a chance to recalculate when\n                                // it should next wake up.\n                                refresher.notify_changes();\n                            }\n                        }\n                    }\n                }\n            }\n        });\n    }\n\n    /// For a [TokenState::Active] token for `act_id`, refresh it, blocking until the token is\n    /// refreshed or an error occurred. This function must be called with a [TokenState::Active]\n    /// tokenstate.\n    ///\n    /// # Panics\n    ///\n    /// If the tokenstate is not [TokenState::Active].\n    fn inner_refresh(\n        &self,\n        pstate: &AuthenticatorState,\n        mut ct_lk: CTGuard,\n        mut act_id: AccountId,\n    ) -> RefreshKind {\n        info!(\"starting inner refresh\");\n        let mut new_ts = ct_lk.tokenstate(act_id).clone();\n        let refresh_token = match new_ts {\n            TokenState::Active {\n                ref refresh_token,\n                ref mut last_refresh_attempt,\n                ..\n            } => match refresh_token {\n                Some(r) => {\n                    *last_refresh_attempt = Some(Instant::now());\n                    let r = r.to_owned();\n                    act_id = ct_lk.tokenstate_replace(act_id, new_ts);\n                    r\n                }\n                None => {\n                    ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                    return RefreshKind::NoRefreshToken;\n                }\n            },\n            _ => unreachable!(\"tokenstate is not TokenState::Active\"),\n        };\n\n        let act = ct_lk.account(act_id);\n        let token_uri = act.token_uri.clone();\n        let client_id = act.client_id.clone();\n        let mut pairs = vec![\n            (\"client_id\", client_id.as_str()),\n            (\"refresh_token\", refresh_token.as_str()),\n            (\"grant_type\", \"refresh_token\"),\n        ];\n        let client_secret = act.client_secret.clone();\n        if let Some(ref x) = client_secret {\n            pairs.push((\"client_secret\", x));\n        }\n\n        drop(ct_lk);\n        let agent_conf = ureq::Agent::config_builder()\n            .timeout_global(Some(UREQ_TIMEOUT))\n            .build();\n        let body = match ureq::Agent::new_with_config(agent_conf)\n            .post(token_uri.as_str())\n            .send_form(pairs)\n        {\n            Ok(response) => match response.into_body().read_to_string() {\n                Ok(s) => s,\n                Err(e) => return RefreshKind::TransitoryError(act_id, e.to_string()),\n            },\n            Err(ureq::Error::StatusCode(code)) => {\n                let reason = format!(\"HTTP code {code}\");\n                if let 408 | 429 | 500 | 502 | 503 | 504 = code {\n                    return RefreshKind::TransitoryError(act_id, reason);\n                } else {\n                    let mut ct_lk = pstate.ct_lock();\n                    if ct_lk.is_act_id_valid(act_id) {\n                        ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                        return RefreshKind::PermanentError(reason);\n                    } else {\n                        return RefreshKind::AccountOrTokenStateChanged;\n                    }\n                }\n            }\n            Err(\n                e @ ureq::Error::ConnectionFailed\n                | e @ ureq::Error::HostNotFound\n                | e @ ureq::Error::Io(_)\n                | e @ ureq::Error::Timeout(_),\n            ) => return RefreshKind::TransitoryError(act_id, e.to_string()),\n            Err(e) => {\n                let mut ct_lk = pstate.ct_lock();\n                if ct_lk.is_act_id_valid(act_id) {\n                    ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                    return RefreshKind::PermanentError(e.to_string());\n                } else {\n                    return RefreshKind::AccountOrTokenStateChanged;\n                }\n            }\n        };\n\n        let parsed = match serde_json::from_str::<Value>(&body) {\n            Ok(v) => {\n                if let Some(e) = v[\"error\"].as_str() {\n                    let mut ct_lk = pstate.ct_lock();\n                    if ct_lk.is_act_id_valid(act_id) {\n                        let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                        let msg =\n                            format!(\"Refreshing {} failed: {}\", ct_lk.account(act_id).name, e);\n                        return RefreshKind::PermanentError(msg);\n                    } else {\n                        return RefreshKind::AccountOrTokenStateChanged;\n                    }\n                }\n                v\n            }\n            Err(e) => {\n                let mut ct_lk = pstate.ct_lock();\n                if ct_lk.is_act_id_valid(act_id) {\n                    let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                    let msg = format!(\"Refreshing {} failed: {e}\", ct_lk.account(act_id).name);\n                    return RefreshKind::PermanentError(msg);\n                } else {\n                    return RefreshKind::AccountOrTokenStateChanged;\n                }\n            }\n        };\n\n        match (\n            parsed[\"access_token\"].as_str(),\n            parsed[\"expires_in\"].as_u64(),\n            parsed[\"token_type\"].as_str(),\n        ) {\n            (Some(access_token), Some(expires_in), Some(\"Bearer\")) => {\n                let refresh_token = match parsed.get(\"refresh_token\") {\n                    None => Some(refresh_token),\n                    Some(Value::String(x)) => Some(x.to_owned()),\n                    Some(_) => None,\n                };\n                let now = Instant::now();\n                let mut ct_lk = pstate.ct_lock();\n                if ct_lk.is_act_id_valid(act_id) {\n                    let expiry = match expiry_instant(&ct_lk, act_id, now, expires_in) {\n                        Ok(x) => x,\n                        Err(e) => {\n                            ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                            return RefreshKind::PermanentError(format!(\"{e}\"));\n                        }\n                    };\n                    ct_lk.tokenstate_replace(\n                        act_id,\n                        TokenState::Active {\n                            access_token: access_token.to_owned(),\n                            access_token_obtained: now,\n                            access_token_expiry: expiry,\n                            ongoing_refresh: false,\n                            consecutive_refresh_fails: 0,\n                            last_refresh_attempt: None,\n                            refresh_token,\n                        },\n                    );\n                    drop(ct_lk);\n                    RefreshKind::Refreshed\n                } else {\n                    RefreshKind::AccountOrTokenStateChanged\n                }\n            }\n            _ => {\n                let mut ct_lk = pstate.ct_lock();\n                if ct_lk.is_act_id_valid(act_id) {\n                    ct_lk.tokenstate_replace(act_id, TokenState::Empty);\n                    RefreshKind::PermanentError(\"Received JSON in unexpected format\".to_string())\n                } else {\n                    RefreshKind::AccountOrTokenStateChanged\n                }\n            }\n        }\n    }\n\n    /// If `act_id` has an active token, return the time when that token should be refreshed.\n    fn refresh_at(\n        &self,\n        _pstate: &AuthenticatorState,\n        ct_lk: &CTGuard,\n        act_id: AccountId,\n    ) -> Option<Instant> {\n        match ct_lk.tokenstate(act_id) {\n            TokenState::Active {\n                access_token_obtained,\n                access_token_expiry,\n                ongoing_refresh,\n                last_refresh_attempt,\n                ..\n            } if !ongoing_refresh => {\n                let act = &ct_lk.account(act_id);\n                if let Some(lra) = last_refresh_attempt {\n                    // There are two ways for `last_refresh_attempt` to be non-`None`:\n                    //   1. The token expired (i.e. last_refresh_attempt > expiry).\n                    //   2. The user tried manually refreshing the token but that refreshing has\n                    //      not yet succeeded (and it is possible that last_refresh_attempt <\n                    //      expiry).\n                    // If the second case occurs, we assume that the user knows that the token\n                    // really needs refreshing, and we treat the token as if it had expired.\n                    if let Some(t) = lra.checked_add(act.refresh_retry(ct_lk.config())) {\n                        return Some(t.to_owned());\n                    }\n                }\n\n                let mut expiry = access_token_expiry\n                    .checked_sub(act.refresh_before_expiry(ct_lk.config()))\n                    .unwrap_or_else(|| cmp::min(Instant::now(), *access_token_expiry));\n\n                // There is no concept of Instant::MAX, so if `access_token_obtained + d` exceeds\n                // Instant's bounds, there's nothing we can fall back on.\n                if let Some(t) =\n                    access_token_obtained.checked_add(act.refresh_at_least(ct_lk.config()))\n                {\n                    expiry = cmp::min(expiry, t);\n                }\n                Some(expiry.to_owned())\n            }\n            _ => None,\n        }\n    }\n\n    fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {\n        let ct_lk = pstate.ct_lock();\n        ct_lk\n            .act_ids()\n            .filter_map(|act_id| self.refresh_at(pstate, &ct_lk, act_id))\n            .min()\n            .map(\n                |act_min| match Instant::now().checked_add(Duration::from_secs(MAX_WAIT_SECS)) {\n                    Some(x) => cmp::min(act_min, x),\n                    None => act_min,\n                },\n            )\n    }\n\n    /// Notify the refresher that one or more [TokenState]s is likely to have changed in a way that\n    /// effects the refresher.\n    pub fn notify_changes(&self) {\n        let mut refresh_lk = self.pred.lock().unwrap();\n        *refresh_lk = true;\n        self.condvar.notify_one();\n    }\n\n    /// Start the refresher thread.\n    pub fn refresher(\n        self: Arc<Self>,\n        pstate: Arc<AuthenticatorState>,\n    ) -> Result<(), Box<dyn Error>> {\n        let refresher = Arc::clone(&self);\n        thread::spawn(move || loop {\n            let next_wakeup = refresher.next_wakeup(&pstate);\n            let mut refresh_lk = refresher.pred.lock().unwrap();\n            while !*refresh_lk {\n                match next_wakeup {\n                    Some(t) => match t.checked_duration_since(Instant::now()) {\n                        Some(d) => {\n                            #[cfg(debug_assertions)]\n                            debug!(\"Refresher: next wakeup {}\", d.as_secs());\n                            refresh_lk = refresher.condvar.wait_timeout(refresh_lk, d).unwrap().0\n                        }\n                        None => break,\n                    },\n                    None => {\n                        #[cfg(debug_assertions)]\n                        debug!(\"Refresher: next wakeup <indefinite>\");\n                        refresh_lk = refresher.condvar.wait(refresh_lk).unwrap();\n                    }\n                }\n            }\n\n            *refresh_lk = false;\n            drop(refresh_lk);\n\n            let ct_lk = pstate.ct_lock();\n            let now = Instant::now();\n            let to_refresh = ct_lk\n                .act_ids()\n                .filter(\n                    |act_id| match refresher.refresh_at(&pstate, &ct_lk, *act_id) {\n                        Some(t) => t <= now,\n                        None => false,\n                    },\n                )\n                .collect::<HashSet<_>>();\n            drop(ct_lk);\n\n            for act_id in to_refresh.iter() {\n                refresher.sched_refresh(Arc::clone(&pstate), *act_id);\n            }\n        });\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/server/request_token.rs",
    "content": "use std::{error::Error, sync::Arc};\n\nuse base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};\nuse rand::{rng, Rng};\nuse sha2::{Digest, Sha256};\nuse url::Url;\n\nuse super::{AccountId, AuthenticatorState, CTGuard, TokenState, CODE_VERIFIER_LEN, STATE_LEN};\n\n/// Request a new token for `act_id`, whose tokenstate must be `Empty`.\npub fn request_token(\n    pstate: Arc<AuthenticatorState>,\n    mut ct_lk: CTGuard,\n    act_id: AccountId,\n) -> Result<Url, Box<dyn Error>> {\n    assert!(matches!(\n        ct_lk.tokenstate(act_id),\n        TokenState::Empty | TokenState::Pending { .. }\n    ));\n\n    let act = ct_lk.account(act_id);\n\n    let mut state = [0u8; STATE_LEN];\n    rng().fill_bytes(&mut state);\n    let state = URL_SAFE_NO_PAD.encode(state);\n\n    let mut code_verifier = [0u8; CODE_VERIFIER_LEN];\n    rng().fill_bytes(&mut code_verifier);\n    let code_verifier = URL_SAFE_NO_PAD.encode(code_verifier);\n    let mut hasher = Sha256::new();\n    hasher.update(&code_verifier);\n    let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());\n\n    let scopes_join = act.scopes.join(\" \");\n    let redirect_uri = act\n        .redirect_uri(pstate.http_port, pstate.https_port)?\n        .to_string();\n    let mut params = vec![\n        (\"access_type\", \"offline\"),\n        (\"code_challenge\", &code_challenge),\n        (\"code_challenge_method\", \"S256\"),\n        (\"client_id\", act.client_id.as_str()),\n        (\"redirect_uri\", redirect_uri.as_str()),\n        (\"response_type\", \"code\"),\n        (\"state\", &state),\n    ];\n    if !act.scopes.is_empty() {\n        params.push((\"scope\", scopes_join.as_str()));\n    }\n    for (k, v) in &act.auth_uri_fields {\n        params.push((k.as_str(), v.as_str()));\n    }\n    let url = Url::parse_with_params(ct_lk.account(act_id).auth_uri.as_str(), &params)?;\n    ct_lk.tokenstate_replace(\n        act_id,\n        TokenState::Pending {\n            code_verifier,\n            last_notification: None,\n            url: url.clone(),\n            state,\n        },\n    );\n    drop(ct_lk);\n    pstate.notifier.notify_changes();\n    Ok(url)\n}\n"
  },
  {
    "path": "src/server/state.rs",
    "content": "//! This module contains pizauth's core central state. [AuthenticatorState] is the global state,\n//! but mostly what one is interested in are [Account]s and [TokenState]s. These are (literally)\n//! locked together: every [Account] has a [TokenState] and vice versa. However, a challenge is\n//! that we allow users to reload their config at any point: we have to be very careful about\n//! associating an [Account] with a [TokenState], as we don't want to hand out credentials for an\n//! old version of an account.\n//!\n//! To that end, we provide an abstraction [AccountId] which is a sort-of \"the current version of\n//! an [Account]\". Any change to the user's configuration of an [Account] *or* a change to an\n//! [Account]'s associated [TokenState] will cause the [AccountId] to change. Every time a\n//! [CTGuard] is dropped/reacquired, or [tokenstate_replace] is called, [AccountId]s must be\n//! revalidated. Failing to do so will cause panics.\n\nuse std::{\n    collections::HashMap,\n    error::Error,\n    path::PathBuf,\n    sync::{Arc, Mutex, MutexGuard},\n    time::SystemTime,\n};\n\nuse boot_time::Instant;\nuse chacha20poly1305::{\n    aead::{Aead, KeyInit},\n    ChaCha20Poly1305, Key, Nonce,\n};\nuse rand::{rng, RngExt};\nuse serde::{Deserialize, Serialize};\nuse url::Url;\nuse wincode::{deserialize, serialize, SchemaRead, SchemaWrite};\n\nuse super::{eventer::Eventer, notifier::Notifier, refresher::Refresher};\nuse crate::config::{Account, AccountDump, Config};\n\n/// We lightly encrypt the dump output to make it at least resistant to simple string-based\n/// grepping. This is the length of the dump nonce.\nconst NONCE_LEN: usize = 12;\n/// The ChaCha20 key for the dump.\nconst CHACHA20_KEY: &[u8; 32] = b\"\\x66\\xa2\\x47\\xa8\\x5e\\x48\\xcf\\xec\\xaa\\xed\\x9b\\x36\\xeb\\xa9\\x7d\\x53\\x50\\xd4\\x28\\x63\\x75\\x09\\x7a\\x44\\xee\\xff\\xb9\\xc4\\x54\\x6b\\x65\\xa3\";\n/// The format of the dump. Monotonically increment if the semantics of the `pizauth dump` change\n/// in an incompatible manner.\nconst DUMP_VERSION: u64 = 1;\n\n/// pizauth's global state.\npub struct AuthenticatorState {\n    pub conf_path: PathBuf,\n    /// The \"global lock\" protecting the config and current [TokenState]s. Can only be accessed via\n    /// [AuthenticatorState::ct_lock].\n    locked_state: Mutex<LockedState>,\n    /// Port of the HTTP server required by OAuth.\n    pub http_port: Option<u16>,\n    /// Port of the HTTPS server required by OAuth.\n    pub https_port: Option<u16>,\n    /// If an HTTPS server is running, its raw public key formatted in hex with each byte separated by `:`.\n    pub https_pub_key: Option<String>,\n    pub eventer: Arc<Eventer>,\n    pub notifier: Arc<Notifier>,\n    pub refresher: Arc<Refresher>,\n}\n\nimpl AuthenticatorState {\n    pub fn new(\n        conf_path: PathBuf,\n        conf: Config,\n        http_port: Option<u16>,\n        https_port: Option<u16>,\n        https_pub_key: Option<String>,\n        eventer: Arc<Eventer>,\n        notifier: Arc<Notifier>,\n        refresher: Arc<Refresher>,\n    ) -> Self {\n        AuthenticatorState {\n            conf_path,\n            locked_state: Mutex::new(LockedState::new(conf)),\n            http_port,\n            https_port,\n            https_pub_key,\n            eventer,\n            notifier,\n            refresher,\n        }\n    }\n\n    /// Lock the config and tokens and return a guard.\n    ///\n    /// # Panics\n    ///\n    /// If another thread poisoned the underlying lock, this function will panic. There is little\n    /// to be done in such a case, as it is likely that pizauth is in an inconsistent, and\n    /// irretrievable, state.\n    pub fn ct_lock(&self) -> CTGuard<'_> {\n        CTGuard::new(self.locked_state.lock().unwrap())\n    }\n\n    /// Update the global [Config] to `new_conf`. This cannot fail, but note that there is no\n    /// guarantee that by the time this function calls the configuration is still the same as\n    /// `new_conf` since another thread(s) may also have called this function.\n    pub fn update_conf(&self, new_conf: Config) {\n        {\n            let mut lk = self.locked_state.lock().unwrap();\n            lk.update_conf(new_conf);\n        }\n        self.notifier.notify_changes();\n        self.refresher.notify_changes();\n    }\n\n    pub fn dump(&self) -> Result<Vec<u8>, Box<dyn Error>> {\n        let lk = self.locked_state.lock().unwrap();\n        let d = lk.dump()?;\n        drop(lk);\n\n        // The aim of encrypting the dump output isn't to render it impossible to decrypt, but to\n        // at least make it harder for people to shoot themselves in the foot by leaving important\n        // information lurking on a file system in a way that `grep` or `strings` can easily find.\n        let key = Key::from_slice(CHACHA20_KEY);\n        let cipher = ChaCha20Poly1305::new(key);\n        let mut nonce = [0u8; NONCE_LEN];\n        rng().fill(&mut nonce[..]);\n        let nonce = Nonce::from_slice(&nonce);\n        let bytes = cipher\n            .encrypt(nonce, &*d)\n            .map_err(|_| \"Creating dump failed.\")?;\n        let mut buf = Vec::from(nonce.as_slice());\n        buf.extend(&bytes);\n        Ok(buf)\n    }\n\n    pub fn restore(&self, d: Vec<u8>) -> Result<(), Box<dyn Error>> {\n        if d.len() < NONCE_LEN {\n            return Err(\"Input too short\")?;\n        }\n        let key = Key::from_slice(CHACHA20_KEY);\n        let cipher = ChaCha20Poly1305::new(key);\n        let nonce = &d[..NONCE_LEN];\n        let encrypted = &d[NONCE_LEN..];\n        let d = cipher\n            .decrypt(Nonce::from_slice(nonce), encrypted.as_ref())\n            .map_err(|_| \"Restoring dump failed\")?;\n\n        {\n            let mut lk = self.locked_state.lock().unwrap();\n            lk.restore(d)?;\n        }\n        self.notifier.notify_changes();\n        self.refresher.notify_changes();\n        Ok(())\n    }\n}\n\n/// An invariant \"I1\" that must be maintained at all times is that the set of keys in\n/// `LockedState.config.Config.accounts` must exactly equal `LockedState.tokenstates`. This\n/// invariant is relied upon by a number of `unwrap` calls which assume that if a key `x` was found\n/// in one of these sets it is guaranteed to be found in the other.\nstruct LockedState {\n    config: Config,\n    details: Vec<(String, AccountId, TokenState)>,\n    /// The next [AccountId] we'll hand out.\n    ///\n    // The account ID may change frequently, and if it wraps, we lose correctness, so we use a\n    // ludicrously large type. On my current desktop machine a quick measurement suggests that if\n    // this was incremented at the maximum possible continuous rate, it would take about\n    // 4,522,155,402,651,803,058,176 years before this wrapped. In contrast if we were to,\n    // recklessly, use a u64 it could wrap in a blink-and-you-miss-it 245 years.\n    next_account_id: u128,\n}\n\nimpl LockedState {\n    fn new(config: Config) -> Self {\n        let mut details = Vec::with_capacity(config.accounts.len());\n\n        let mut next_account_id = 0;\n        for act_name in config.accounts.keys() {\n            let act_id = AccountId {\n                id: next_account_id,\n            };\n            next_account_id += 1;\n            details.push((act_name.to_owned(), act_id, TokenState::Empty));\n        }\n\n        LockedState {\n            next_account_id,\n            config,\n            details,\n        }\n    }\n\n    fn update_conf(&mut self, config: Config) {\n        let mut details = Vec::with_capacity(config.accounts.len());\n\n        for act_name in config.accounts.keys() {\n            if let Some(old_act) = self.config.accounts.get(act_name) {\n                let new_act = &config.accounts[act_name];\n                if new_act.secure_eq(old_act) {\n                    // We know that `self.details` must contain `act_name` so the unwrap is safe.\n                    details.push(\n                        self.details\n                            .iter()\n                            .find(|x| x.0 == act_name.as_str())\n                            .unwrap()\n                            .clone(),\n                    );\n                } else {\n                    // The two accounts are not the same so we can't reuse the existing tokenstate,\n                    // instead keeping it as Empty. However, we need to increment the version\n                    // number, because there could be a very long-running thread that started\n                    // acting on an Empty tokenstate, did something (very slowly), and now wants to\n                    // update its status, even though multiple other updates have happened in the\n                    // interim. Incrementing the version implicitly invalidates whatever (slow...)\n                    // calculation it has performed.\n                    details.push((\n                        act_name.to_owned(),\n                        self.next_account_id(),\n                        TokenState::Empty,\n                    ));\n                }\n            } else {\n                details.push((\n                    act_name.to_owned(),\n                    self.next_account_id(),\n                    TokenState::Empty,\n                ));\n            }\n        }\n\n        self.config = config;\n        self.details = details;\n    }\n\n    fn dump(&self) -> Result<Vec<u8>, Box<dyn Error>> {\n        let mut acts = HashMap::with_capacity(self.details.len());\n        for (act_name, _, ts) in &self.details {\n            acts.insert(\n                act_name.to_owned(),\n                (self.config.accounts[act_name.as_str()].dump(), ts.dump()),\n            );\n        }\n\n        Ok(serialize(&Dump {\n            version: DUMP_VERSION,\n            accounts: acts,\n        })?)\n    }\n\n    fn restore(&mut self, dump: Vec<u8>) -> Result<(), Box<dyn Error>> {\n        let d: Dump = deserialize(&dump)?;\n        if d.version != DUMP_VERSION {\n            return Err(\"Unknown dump version\".into());\n        }\n\n        let mut restore = HashMap::new();\n        for (act_name, _, old_ts) in &self.details {\n            let act = &self.config.accounts[act_name.as_str()];\n            if let Some((act_dump, ts_dump)) = d.accounts.get(act_name) {\n                if act.secure_restorable(act_dump) {\n                    let new_ts = TokenState::restore(ts_dump);\n                    match (old_ts, &new_ts) {\n                        (\n                            &TokenState::Empty | &TokenState::Pending { .. },\n                            &TokenState::Empty | &TokenState::Pending { .. },\n                        ) => (),\n                        (\n                            &TokenState::Empty | &TokenState::Pending { .. },\n                            &TokenState::Active { .. },\n                        ) => {\n                            restore.insert(act_name.to_owned(), new_ts);\n                        }\n                        (&TokenState::Active { .. }, _) => (),\n                    }\n                }\n            }\n        }\n\n        for (act_name, ts) in restore.drain() {\n            let ts_idx = self\n                .details\n                .iter()\n                .position(|(n, _, _)| n == &act_name)\n                .unwrap();\n            self.details[ts_idx].1 = self.next_account_id();\n            self.details[ts_idx].2 = ts;\n        }\n\n        Ok(())\n    }\n\n    /// Returns a unique [AccountId].\n    fn next_account_id(&mut self) -> AccountId {\n        let new_id = AccountId {\n            id: self.next_account_id,\n        };\n        self.next_account_id += 1;\n        new_id\n    }\n}\n\n#[derive(Deserialize, Serialize, SchemaRead, SchemaWrite)]\nstruct Dump {\n    version: u64,\n    accounts: HashMap<String, (AccountDump, TokenStateDump)>,\n}\n\n/// A lock guard around the [Config] and tokens. When this guard is dropped:\n///\n///   1. the config lock will be released.\n///   2. any [AccountId] instances created from this [CTGuard] will no longer by valid\n///      i.e. they will not be able to access [Account]s or [TokenState]s until they are\n///      revalidated.\npub struct CTGuard<'a> {\n    guard: MutexGuard<'a, LockedState>,\n}\n\nimpl<'a> CTGuard<'a> {\n    fn new(guard: MutexGuard<'a, LockedState>) -> CTGuard<'a> {\n        CTGuard { guard }\n    }\n\n    pub fn config(&self) -> &Config {\n        &self.guard.config\n    }\n\n    /// If `act_name` references a current account, return a [AccountId].\n    pub fn validate_act_name(&self, act_name: &str) -> Option<AccountId> {\n        self.guard\n            .details\n            .iter()\n            .find(|x| x.0 == act_name)\n            .map(|x| x.1)\n    }\n\n    /// Is `act_id` still a valid [AccountId]?\n    pub fn is_act_id_valid(&self, act_id: AccountId) -> bool {\n        self.guard.details.iter().any(|x| x.1 == act_id)\n    }\n\n    /// An iterator that will produce one [AccountId] for each currently active account.\n    pub fn act_ids(&self) -> impl Iterator<Item = AccountId> + '_ {\n        self.guard.details.iter().map(|x| x.1)\n    }\n\n    /// Return the [AccountId] with state `state`.\n    pub fn act_id_matching_token_state(&self, state: &str) -> Option<AccountId> {\n        self.guard\n            .details\n            .iter()\n            .find(|x| matches!(&x.2, TokenState::Pending { state: s, .. } if s == state))\n            .map(|x| x.1)\n    }\n\n    /// Return the [Account] for account `act_id`.\n    ///\n    /// # Panics\n    ///\n    /// If `act_id` is not valid.\n    pub fn account(&self, act_id: AccountId) -> &Account {\n        let act_name = self\n            .guard\n            .details\n            .iter()\n            .find(|x| x.1 == act_id)\n            .map(|x| &x.0)\n            .unwrap();\n        &self.guard.config.accounts[act_name]\n    }\n\n    /// Return a reference to the [TokenState] of `act_id`. The user must have validated `act_id`\n    /// under the current [CTGuard].\n    ///\n    /// # Panics\n    ///\n    /// If `act_id` is not valid.\n    pub fn tokenstate(&self, act_id: AccountId) -> &TokenState {\n        self.guard\n            .details\n            .iter()\n            .find(|x| x.1 == act_id)\n            .map(|x| &x.2)\n            .unwrap()\n    }\n\n    /// If `act_id` is `Active`, set `ongoing_refresh` to `new_ongoing_refresh` and return the new\n    /// `AccountId`.\n    ///\n    /// # Panics\n    ///\n    /// If `act_id` is not valid or is not `Active`.\n    pub fn tokenstate_set_ongoing_refresh(\n        &mut self,\n        act_id: AccountId,\n        new_ongoing_refresh: bool,\n    ) -> AccountId {\n        let i = self\n            .guard\n            .details\n            .iter()\n            .position(|x| x.1 == act_id)\n            .unwrap();\n\n        let new_id = self.guard.next_account_id();\n        let ts = &mut self.guard.details[i];\n        if let TokenState::Active {\n            ref mut ongoing_refresh,\n            ..\n        } = ts.2\n        {\n            ts.1 = new_id;\n            *ongoing_refresh = new_ongoing_refresh;\n            return new_id;\n        }\n        unreachable!();\n    }\n\n    /// Update the tokenstate for `act_id` to `new_tokenstate` returning a new [AccountId]\n    /// valid for the new tokenstate, updating the tokenstate version.\n    ///\n    /// # Panics\n    ///\n    /// If `act_id` is not valid.\n    pub fn tokenstate_replace(\n        &mut self,\n        act_id: AccountId,\n        new_tokenstate: TokenState,\n    ) -> AccountId {\n        let i = self\n            .guard\n            .details\n            .iter()\n            .position(|x| x.1 == act_id)\n            .unwrap();\n        let new_id = self.guard.next_account_id();\n        self.guard.details[i].1 = new_id;\n        self.guard.details[i].2 = new_tokenstate;\n        new_id\n    }\n}\n\n/// An account ID. Must be created by [LockedState::next_account_id].\n#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq)]\npub struct AccountId {\n    id: u128,\n}\n\n#[derive(Clone, Debug)]\npub enum TokenState {\n    /// Authentication is neither pending nor active.\n    Empty,\n    /// Pending authentication\n    Pending {\n        code_verifier: String,\n        last_notification: Option<Instant>,\n        state: String,\n        url: Url,\n    },\n    /// There is an active token (and, possibly, also an active refresh token).\n    Active {\n        access_token: String,\n        /// When did we obtain the current access_token?\n        access_token_obtained: Instant,\n        /// When does the current access token expire?\n        access_token_expiry: Instant,\n        /// We may have been given a refresh token which may allow us to obtain another access\n        /// token when the existing one expires (notice the two \"may\"s!). The remaining fields in\n        /// the `Active` variant are only relevant if `refresh_token` is `Some(...)`.\n        refresh_token: Option<String>,\n        /// Is the refresher currently trying to refresh this token?\n        ongoing_refresh: bool,\n        /// How many times in a row has refreshing failed? This will be reset to zero when\n        /// refreshing succeeds.\n        consecutive_refresh_fails: u64,\n        /// The instant in time when the last ongoing, or unsuccessful, refresh attempt was made.\n        last_refresh_attempt: Option<Instant>,\n    },\n}\n\n#[derive(Deserialize, Serialize, SchemaRead, SchemaWrite)]\n/// The format of a dumped [TokenState]. Note that [std::time::Instant] instances are translated to\n/// [std::time::SystemTime] instances: there is no guarantee that we can precisely represent the\n/// latter as the former, so when conversions fail we default to setting values to\n/// [std::time::Instant::now()] or [std::time::SystemTime::now()], as appropriate, as a safe\n/// fallback.\npub enum TokenStateDump {\n    Empty,\n    Active {\n        access_token: String,\n        access_token_obtained: SystemTime,\n        access_token_expiry: SystemTime,\n        refresh_token: Option<String>,\n    },\n}\n\nimpl TokenState {\n    pub fn dump(&self) -> TokenStateDump {\n        fn dump_instant(i: &Instant) -> SystemTime {\n            let t;\n            if let Some(d) = i.checked_duration_since(Instant::now()) {\n                // Instant is in the future\n                t = SystemTime::now().checked_add(d);\n            } else if let Some(d) = Instant::now().checked_duration_since(*i) {\n                // Instant is in the past\n                t = SystemTime::now().checked_sub(d);\n            } else {\n                t = None;\n            }\n            t.unwrap_or_else(SystemTime::now)\n        }\n\n        match self {\n            TokenState::Empty => TokenStateDump::Empty,\n            TokenState::Pending { .. } => TokenStateDump::Empty,\n            TokenState::Active {\n                access_token,\n                access_token_obtained,\n                access_token_expiry,\n                refresh_token,\n                ongoing_refresh: _,\n                consecutive_refresh_fails: _,\n                last_refresh_attempt: _,\n            } => TokenStateDump::Active {\n                access_token: access_token.to_owned(),\n                access_token_obtained: dump_instant(access_token_obtained),\n                access_token_expiry: dump_instant(access_token_expiry),\n                refresh_token: refresh_token.clone(),\n            },\n        }\n    }\n\n    pub fn restore(tsd: &TokenStateDump) -> TokenState {\n        fn restore_instant(t: &SystemTime) -> Instant {\n            let i;\n            if let Ok(d) = t.duration_since(SystemTime::now()) {\n                // SystemTime is in the future\n                i = Instant::now().checked_add(d);\n            } else if let Ok(d) = SystemTime::now().duration_since(*t) {\n                // SystemTime is in the past\n                i = Instant::now().checked_sub(d);\n            } else {\n                i = None;\n            }\n            i.unwrap_or_else(Instant::now)\n        }\n\n        match tsd {\n            TokenStateDump::Empty => TokenState::Empty,\n            TokenStateDump::Active {\n                access_token,\n                access_token_obtained,\n                access_token_expiry,\n                refresh_token,\n            } => TokenState::Active {\n                access_token: access_token.clone(),\n                access_token_obtained: restore_instant(access_token_obtained),\n                access_token_expiry: restore_instant(access_token_expiry),\n                refresh_token: refresh_token.clone(),\n                ongoing_refresh: false,\n                consecutive_refresh_fails: 0,\n                last_refresh_attempt: None,\n            },\n        }\n    }\n}\n\n#[cfg(test)]\nmod test {\n    use super::*;\n    use crate::server::refresher::Refresher;\n    use std::time::Duration;\n\n    #[test]\n    fn test_act_validation() {\n        let conf1_str = r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n            \"#;\n        let conf2_str = r#\"\n            account \"x\" {\n                auth_uri = \"http://h.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n            \"#;\n        let conf3_str = r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n\n            account \"y\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n            \"#;\n\n        let conf = Config::from_str(conf1_str).unwrap();\n        let eventer = Arc::new(Eventer::new().unwrap());\n        let notifier = Arc::new(Notifier::new().unwrap());\n        let pstate = AuthenticatorState::new(\n            PathBuf::new(),\n            conf,\n            Some(0),\n            Some(0),\n            Some(\"\".to_string()),\n            eventer,\n            notifier,\n            Refresher::new(),\n        );\n        let mut old_x_id;\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            old_x_id = act_id;\n            assert_eq!(act_id, AccountId { id: 0 });\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n        }\n\n        let conf = Config::from_str(conf2_str).unwrap();\n        pstate.update_conf(conf);\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(act_id, old_x_id);\n            old_x_id = act_id;\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n        }\n\n        let conf = Config::from_str(conf2_str).unwrap();\n        pstate.update_conf(conf);\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_eq!(act_id, old_x_id);\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n        }\n\n        let conf = Config::from_str(conf3_str).unwrap();\n        pstate.update_conf(conf);\n        let old_y_ver;\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(act_id, old_x_id);\n            old_x_id = act_id;\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n\n            let act_id = ct_lk.validate_act_name(\"y\").unwrap();\n            old_y_ver = act_id.id;\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n        }\n\n        let conf = Config::from_str(conf2_str).unwrap();\n        pstate.update_conf(conf);\n        {\n            let ct_lk = pstate.ct_lock();\n\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(act_id, old_x_id);\n            old_x_id = act_id;\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n\n            assert!(ct_lk.validate_act_name(\"y\").is_none());\n            assert!(!ct_lk.is_act_id_valid(AccountId { id: old_y_ver }));\n        }\n\n        {\n            let mut ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            let act_id = ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Pending {\n                    code_verifier: \"abc\".to_owned(),\n                    last_notification: None,\n                    state: \"xyz\".to_string(),\n                    url: Url::parse(\"http://a.com/\").unwrap(),\n                },\n            );\n            assert_ne!(act_id, old_x_id);\n            old_x_id = act_id;\n            assert!(matches!(\n                ct_lk.tokenstate(act_id),\n                TokenState::Pending { .. }\n            ));\n        }\n\n        let conf = Config::from_str(conf2_str).unwrap();\n        pstate.update_conf(conf);\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_eq!(act_id, old_x_id);\n            assert!(matches!(\n                ct_lk.tokenstate(act_id),\n                TokenState::Pending { .. }\n            ));\n        }\n\n        let conf = Config::from_str(conf1_str).unwrap();\n        pstate.update_conf(conf);\n        {\n            let ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(act_id, old_x_id);\n            assert!(matches!(ct_lk.tokenstate(act_id), TokenState::Empty));\n        }\n    }\n\n    #[test]\n    fn dump_restore() {\n        let conf_str = r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n\n            account \"y\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n            \"#;\n\n        let conf = Config::from_str(conf_str).unwrap();\n        let eventer = Arc::new(Eventer::new().unwrap());\n        let notifier = Arc::new(Notifier::new().unwrap());\n        let pstate = AuthenticatorState::new(\n            PathBuf::new(),\n            conf,\n            Some(0),\n            Some(0),\n            Some(\"\".to_string()),\n            eventer,\n            notifier,\n            Refresher::new(),\n        );\n        let old_x_id;\n        {\n            let ct_lk = pstate.ct_lock();\n            old_x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert!(matches!(ct_lk.tokenstate(old_x_id), TokenState::Empty));\n        }\n        let dump = pstate.dump().unwrap();\n\n        {\n            pstate.restore(dump.clone()).unwrap();\n\n            let ct_lk = pstate.ct_lock();\n            let x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_eq!(old_x_id, x_id);\n            assert!(matches!(ct_lk.tokenstate(x_id), TokenState::Empty));\n        }\n\n        {\n            let mut ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Pending {\n                    code_verifier: \"abc\".to_owned(),\n                    last_notification: None,\n                    state: \"xyz\".to_string(),\n                    url: Url::parse(\"http://a.com/\").unwrap(),\n                },\n            );\n        }\n\n        {\n            pstate.restore(dump.clone()).unwrap();\n\n            let ct_lk = pstate.ct_lock();\n            let x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(old_x_id, x_id);\n            assert!(matches!(ct_lk.tokenstate(x_id), TokenState::Pending { .. }));\n        }\n\n        {\n            let mut ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Active {\n                    access_token: \"abc\".to_owned(),\n                    access_token_obtained: Instant::now(),\n                    access_token_expiry: Instant::now()\n                        .checked_add(Duration::from_secs(60))\n                        .unwrap(),\n                    refresh_token: None,\n                    ongoing_refresh: false,\n                    consecutive_refresh_fails: 0,\n                    last_refresh_attempt: None,\n                },\n            );\n        }\n        let dump = pstate.dump().unwrap();\n\n        {\n            pstate.restore(dump.clone()).unwrap();\n\n            let ct_lk = pstate.ct_lock();\n            let x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert_ne!(old_x_id, x_id);\n            assert!(matches!(ct_lk.tokenstate(x_id), TokenState::Active { .. }));\n        }\n\n        let conf = Config::from_str(conf_str).unwrap();\n        let eventer = Arc::new(Eventer::new().unwrap());\n        let notifier = Arc::new(Notifier::new().unwrap());\n        let pstate = AuthenticatorState::new(\n            PathBuf::new(),\n            conf,\n            Some(0),\n            Some(0),\n            Some(\"\".to_string()),\n            eventer,\n            notifier,\n            Refresher::new(),\n        );\n\n        let old_x_id;\n        {\n            let ct_lk = pstate.ct_lock();\n            old_x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            assert!(matches!(ct_lk.tokenstate(old_x_id), TokenState::Empty));\n        }\n\n        {\n            pstate.restore(dump.clone()).unwrap();\n\n            let ct_lk = pstate.ct_lock();\n            let x_id = ct_lk.validate_act_name(\"x\").unwrap();\n            dbg!(ct_lk.tokenstate(x_id));\n            assert_ne!(old_x_id, x_id);\n            assert!(matches!(ct_lk.tokenstate(x_id), TokenState::Active { .. }));\n        }\n    }\n\n    #[test]\n    fn dump_restore_error() {\n        // Check that if restore fails (a) this is reported correctly (b) it does not perturb the\n        // running state.\n\n        let conf_str = r#\"\n            account \"x\" {\n                auth_uri = \"http://a.com\";\n                client_id = \"b\";\n                client_secret = \"c\";\n                scopes = [\"d\", \"e\"];\n                redirect_uri = \"http://f.com\";\n                token_uri = \"http://g.com\";\n            }\n            \"#;\n\n        let conf = Config::from_str(conf_str).unwrap();\n        let eventer = Arc::new(Eventer::new().unwrap());\n        let notifier = Arc::new(Notifier::new().unwrap());\n        let pstate = AuthenticatorState::new(\n            PathBuf::new(),\n            conf,\n            Some(0),\n            Some(0),\n            Some(\"\".to_string()),\n            eventer,\n            notifier,\n            Refresher::new(),\n        );\n\n        {\n            let mut ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Active {\n                    access_token: \"abc\".to_owned(),\n                    access_token_obtained: Instant::now(),\n                    access_token_expiry: Instant::now()\n                        .checked_add(Duration::from_secs(60))\n                        .unwrap(),\n                    refresh_token: Some(\"refresh\".to_owned()),\n                    ongoing_refresh: false,\n                    consecutive_refresh_fails: 0,\n                    last_refresh_attempt: None,\n                },\n            );\n        }\n\n        let mut accounts = HashMap::new();\n        {\n            let ct_lk = pstate.ct_lock();\n            for (act_name, _, ts) in &ct_lk.guard.details {\n                accounts.insert(\n                    act_name.to_owned(),\n                    (\n                        ct_lk.guard.config.accounts[act_name.as_str()].dump(),\n                        ts.dump(),\n                    ),\n                );\n            }\n        }\n        // Create a dump file with an unsupported version number.\n        let plaintext = serialize(&Dump {\n            version: DUMP_VERSION + 1,\n            accounts,\n        })\n        .unwrap();\n        let key = Key::from_slice(CHACHA20_KEY);\n        let cipher = ChaCha20Poly1305::new(key);\n        let nonce = Nonce::from_slice(&[0; NONCE_LEN]);\n        let mut dump = nonce.as_slice().to_vec();\n        dump.extend(cipher.encrypt(nonce, &*plaintext).unwrap());\n\n        // Move `x` from Active -> Pending. This is a paranoid sanity check that even though\n        // `restore` returns an error, no state has been updated.\n        {\n            let mut ct_lk = pstate.ct_lock();\n            let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n            ct_lk.tokenstate_replace(\n                act_id,\n                TokenState::Pending {\n                    code_verifier: \"abc\".to_owned(),\n                    last_notification: None,\n                    state: \"xyz\".to_string(),\n                    url: Url::parse(\"http://a.com/\").unwrap(),\n                },\n            );\n        }\n\n        assert!(pstate.restore(dump).is_err());\n\n        let ct_lk = pstate.ct_lock();\n        let act_id = ct_lk.validate_act_name(\"x\").unwrap();\n        assert!(matches!(\n            ct_lk.tokenstate(act_id),\n            TokenState::Pending { .. }\n        ));\n    }\n}\n"
  },
  {
    "path": "src/shell_cmd.rs",
    "content": "use std::{env, error::Error, process::Command, time::Duration};\n\nuse wait_timeout::ChildExt;\n\n/// Run the string `cmd` as `$SHELL -c '<cmd>'` with the environment `env`. If the command runs for\n/// longer than `timeout`, it will be sent `SIGKILL`. If any error occurs, `Err` is returned with a\n/// string suitable for reporting to the user.\npub fn shell_cmd<const T: usize>(\n    cmd: &str,\n    env: [(&str, &str); T],\n    timeout: Duration,\n) -> Result<(), Box<dyn Error>> {\n    match env::var(\"SHELL\") {\n        Ok(s) => match Command::new(s).envs(env).args([\"-c\", cmd]).spawn() {\n            Ok(mut child) => match child.wait_timeout(timeout) {\n                Ok(Some(status)) => {\n                    if !status.success() {\n                        return Err(format!(\n                            \"'{cmd:}' returned {}\",\n                            status\n                                .code()\n                                .map(|x| x.to_string())\n                                .unwrap_or_else(|| \"<Unknown exit code\".to_string())\n                        )\n                        .into());\n                    }\n                }\n                Ok(None) => {\n                    child.kill().ok();\n                    child.wait().ok();\n                    return Err(format!(\"'{cmd:}' exceeded timeout\").into());\n                }\n                Err(e) => return Err(format!(\"Waiting on '{cmd:}' failed: {e:}\").into()),\n            },\n            Err(e) => return Err(format!(\"Couldn't execute '{cmd:}': {e:}\").into()),\n        },\n        Err(e) => return Err(format!(\"{e:}\").into()),\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "src/user_sender.rs",
    "content": "use std::{\n    error::Error,\n    io::{stdin, Read, Write},\n    net::Shutdown,\n    os::unix::net::UnixStream,\n    path::Path,\n};\n\nuse crate::server::sock_path;\n\npub fn dump(cache_path: &Path) -> Result<Vec<u8>, Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(\"dump:\".as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut buf = Vec::new();\n    stream.read_to_end(&mut buf)?;\n    Ok(buf)\n}\n\npub fn server_info(cache_path: &Path) -> Result<serde_json::Value, Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(\"info:\".as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut s = String::new();\n    stream.read_to_string(&mut s)?;\n    Ok(serde_json::from_str(&s)?)\n}\n\npub fn refresh(cache_path: &Path, account: &str, with_url: bool) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let with_url = if with_url { \"withurl\" } else { \"withouturl\" };\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(format!(\"refresh:{with_url:} {account:}\").as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"pending\", url] => {\n            Err(format!(\"Access token unavailable until authorised with URL {url:}\").into())\n        }\n        [\"scheduled\", \"\"] => Ok(()),\n        [\"error\", cause] => Err(cause.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n\npub fn reload(cache_path: &Path) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(b\"reload:\")\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"ok\", \"\"] => Ok(()),\n        [\"error\", cause] => Err(cause.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n\npub fn restore(cache_path: &Path) -> Result<(), Box<dyn Error>> {\n    let mut buf = Vec::new();\n    stdin().read_to_end(&mut buf)?;\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(\"restore:\".as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.write_all(&buf).map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"ok\", \"\"] => Ok(()),\n        [\"error\", msg] => Err(msg.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n\npub fn revoke(cache_path: &Path, account: &str) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(format!(\"revoke:{account}\").as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"ok\", \"\"] => Ok(()),\n        [\"error\", cause] => Err(cause.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n\npub fn show_token(cache_path: &Path, account: &str, with_url: bool) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let with_url = if with_url { \"withurl\" } else { \"withouturl\" };\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(format!(\"showtoken:{with_url:} {account:}\").as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"access_token\", x] => {\n            println!(\"{x:}\");\n            Ok(())\n        }\n        [\"pending\", url] => {\n            Err(format!(\"Access token unavailable until authorised with URL {url:}\").into())\n        }\n        [\"error\", cause] => Err(cause.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n\npub fn shutdown(cache_path: &Path) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(b\"shutdown:\")\n        .map_err(|_| \"Socket not writeable\")?;\n    Ok(())\n}\n\npub fn status(cache_path: &Path) -> Result<(), Box<dyn Error>> {\n    let sock_path = sock_path(cache_path);\n    let mut stream = UnixStream::connect(sock_path)\n        .map_err(|_| \"pizauth authenticator not running or not responding\")?;\n    stream\n        .write_all(\"status:\".as_bytes())\n        .map_err(|_| \"Socket not writeable\")?;\n    stream.shutdown(Shutdown::Write)?;\n\n    let mut rtn = String::new();\n    stream.read_to_string(&mut rtn)?;\n    match rtn.splitn(2, ':').collect::<Vec<_>>()[..] {\n        [\"ok\", x] => {\n            println!(\"{x:}\");\n            Ok(())\n        }\n        [\"error\", cause] => Err(cause.into()),\n        _ => Err(format!(\"Malformed response '{rtn:}'\").into()),\n    }\n}\n"
  },
  {
    "path": "tests/basic.rs",
    "content": "use std::{\n    collections::HashMap,\n    ffi::OsStr,\n    fs,\n    io::{BufRead, BufReader, Read, Write},\n    net::{TcpListener, TcpStream},\n    path::{Path, PathBuf},\n    process::{Child, Command, Output},\n    sync::{Arc, Mutex},\n    thread,\n    time::{Duration, Instant},\n};\n\nuse tempfile::TempDir;\nuse url::{form_urlencoded, Url};\n\nconst ACCOUNT: &str = \"test_account\";\nconst CLIENT_ID: &str = \"test_client_id\";\nconst CLIENT_SECRET: &str = \"test_secret\";\nconst CODE: &str = \"test_code\";\nconst ACCESS_TOKEN: &str = \"test_access_token\";\nconst REFRESH_TOKEN: &str = \"test_refresh_token\";\n\nstruct PizauthServer {\n    child: Child,\n    xdg_dir: PathBuf,\n}\n\nimpl PizauthServer {\n    fn start(cwd: &Path, xdg_dir: &Path, configp: &Path, readyp: &Path) -> Self {\n        let child = pizauth_cmd(xdg_dir, [\"server\", \"-d\", \"-c\"])\n            .arg(configp)\n            .current_dir(cwd)\n            .spawn()\n            .unwrap();\n        // There's no better way than just waiting until `readyp` appears.\n        let timeout = Instant::now() + Duration::from_secs(3);\n        while Instant::now() < timeout && !readyp.exists() {\n            thread::sleep(Duration::from_millis(25));\n        }\n        assert!(readyp.exists());\n        PizauthServer {\n            child,\n            xdg_dir: xdg_dir.to_owned(),\n        }\n    }\n}\n\nimpl Drop for PizauthServer {\n    fn drop(&mut self) {\n        let cmd = pizauth_cmd(&self.xdg_dir, [\"shutdown\"]).output();\n        assert!(cmd.unwrap().status.success());\n        // Currently we kill the main server with SIGTERM, so this `wait` would return an error if\n        // we checked!\n        let _ = self.child.wait();\n    }\n}\n\nstruct OAuthServer {\n    addr: String,\n    thread: Option<thread::JoinHandle<()>>,\n}\n\nimpl OAuthServer {\n    fn start() -> Self {\n        let listener = TcpListener::bind(\"127.0.0.1:0\").unwrap();\n        let addr = listener.local_addr().unwrap().to_string();\n        let expected_redirect_uri = Arc::new(Mutex::new(None));\n\n        let thread = {\n            let expected_redirect_uri = Arc::clone(&expected_redirect_uri);\n            thread::spawn(move || {\n                for _ in 0..2 {\n                    let (stream, _) = listener.accept().unwrap();\n                    handle_oauth_request(stream, &expected_redirect_uri);\n                }\n            })\n        };\n\n        OAuthServer {\n            addr,\n            thread: Some(thread),\n        }\n    }\n\n    fn auth_uri(&self) -> String {\n        format!(\"http://{}/authorize\", self.addr)\n    }\n\n    fn token_uri(&self) -> String {\n        format!(\"http://{}/token\", self.addr)\n    }\n\n    fn join(&mut self) {\n        if let Some(thread) = self.thread.take() {\n            thread.join().unwrap();\n        }\n    }\n}\n\nfn pizauth_config(oauths: &OAuthServer) -> String {\n    let auth_uri = oauths.auth_uri();\n    let token_uri = oauths.token_uri();\n    format!(\n        r#\"\nhttp_listen = \"127.0.0.1:0\";\nhttps_listen = none;\nstartup_cmd = \"touch ready\";\n\naccount \"{ACCOUNT}\" {{\n    auth_uri = \"{auth_uri}\";\n    token_uri = \"{token_uri}\";\n    client_id = \"{CLIENT_ID}\";\n    client_secret = \"{CLIENT_SECRET}\";\n}}\n\"#\n    )\n}\n\nfn pending_auth_url(output: &Output) -> Url {\n    let stderr = String::from_utf8_lossy(&output.stderr);\n    let marker = \"Access token unavailable until authorised with URL \";\n    let url = stderr.split(marker).nth(1).unwrap().trim();\n    Url::parse(url).unwrap()\n}\n\nfn pizauth_cmd<I, S>(xdg_dir: &Path, args: I) -> Command\nwhere\n    I: IntoIterator<Item = S>,\n    S: AsRef<OsStr>,\n{\n    let mut cmd = Command::new(env!(\"CARGO_BIN_EXE_pizauth\"));\n    cmd.env(\"XDG_RUNTIME_DIR\", xdg_dir)\n        .env(\"SHELL\", \"/bin/sh\")\n        .args(args);\n    cmd\n}\n\nfn handle_oauth_request(stream: TcpStream, expected_redirect_uri: &Mutex<Option<String>>) {\n    let request = HttpRequest::read(stream);\n    let path = request.target.split('?').next().unwrap();\n    match (request.method.as_str(), path) {\n        (\"GET\", \"/authorize\") => {\n            let url = Url::parse(&format!(\"http://localhost{}\", request.target)).unwrap();\n            let params = url.query_pairs().collect::<HashMap<_, _>>();\n            assert_eq!(\n                params.get(\"access_type\").map(|x| x.as_ref()),\n                Some(\"offline\")\n            );\n            assert_eq!(params.get(\"client_id\").map(|x| x.as_ref()), Some(CLIENT_ID));\n            assert_eq!(\n                params.get(\"code_challenge_method\").map(|x| x.as_ref()),\n                Some(\"S256\")\n            );\n            assert!(params.contains_key(\"code_challenge\"));\n            assert_eq!(\n                params.get(\"response_type\").map(|x| x.as_ref()),\n                Some(\"code\")\n            );\n\n            let redirect_uri = params.get(\"redirect_uri\").unwrap().to_string();\n            let state = params.get(\"state\").unwrap().to_string();\n            *expected_redirect_uri.lock().unwrap() = Some(redirect_uri.clone());\n\n            let mut redirect = Url::parse(&redirect_uri).unwrap();\n            redirect.query_pairs_mut().append_pair(\"code\", CODE);\n            redirect.query_pairs_mut().append_pair(\"state\", &state);\n            request.respond(\n                302,\n                &[(\"Location\", redirect.as_str()), (\"Content-Length\", \"0\")],\n                \"\",\n            );\n        }\n        (\"POST\", \"/token\") => {\n            let params = form_urlencoded::parse(request.body.as_bytes()).collect::<HashMap<_, _>>();\n            assert_eq!(\n                params.get(\"grant_type\").map(|x| x.as_ref()),\n                Some(\"authorization_code\")\n            );\n            assert_eq!(params.get(\"code\").map(|x| x.as_ref()), Some(CODE));\n            assert_eq!(params.get(\"client_id\").map(|x| x.as_ref()), Some(CLIENT_ID));\n            assert_eq!(\n                params.get(\"client_secret\").map(|x| x.as_ref()),\n                Some(CLIENT_SECRET)\n            );\n            assert!(params.contains_key(\"code_verifier\"));\n            assert_eq!(\n                params.get(\"redirect_uri\").map(|x| x.as_ref()),\n                expected_redirect_uri.lock().unwrap().as_deref()\n            );\n\n            request.respond(\n                200,\n                &[(\"Content-Type\", \"application/json\")],\n                &format!(\n                    r#\"{{\n                        \"token_type\": \"Bearer\",\n                        \"expires_in\": 3600,\n                        \"access_token\": \"{ACCESS_TOKEN}\",\n                        \"refresh_token\": \"{REFRESH_TOKEN}\"\n                    }}\"#\n                ),\n            );\n        }\n        _ => panic!(\n            \"unexpected OAuth request: {} {}\",\n            request.method, request.target\n        ),\n    }\n}\n\nstruct HttpRequest {\n    stream: TcpStream,\n    method: String,\n    target: String,\n    body: String,\n}\n\nimpl HttpRequest {\n    fn read(stream: TcpStream) -> Self {\n        let mut reader = BufReader::new(stream);\n        let mut request_line = String::new();\n        reader.read_line(&mut request_line).unwrap();\n        let mut parts = request_line.trim_end().split(' ');\n        let method = parts.next().unwrap().to_owned();\n        let target = parts.next().unwrap().to_owned();\n\n        let mut headers = HashMap::new();\n        loop {\n            let mut line = String::new();\n            reader.read_line(&mut line).unwrap();\n            let line = line.trim_end();\n            if line.is_empty() {\n                break;\n            }\n            if let Some((name, value)) = line.split_once(':') {\n                headers.insert(name.to_ascii_lowercase(), value.trim_start().to_owned());\n            }\n        }\n\n        let content_length = headers\n            .get(\"content-length\")\n            .map(|x| x.parse::<usize>().unwrap())\n            .unwrap_or(0);\n        let mut body = vec![0; content_length];\n        reader.read_exact(&mut body).unwrap();\n        let stream = reader.into_inner();\n\n        HttpRequest {\n            stream,\n            method,\n            target,\n            body: String::from_utf8(body).unwrap(),\n        }\n    }\n\n    fn respond(mut self, status: u16, headers: &[(&str, &str)], body: &str) {\n        write!(self.stream, \"HTTP/1.1 {status}\\r\\n\").unwrap();\n        for (name, value) in headers {\n            write!(self.stream, \"{name}: {value}\\r\\n\").unwrap();\n        }\n        write!(self.stream, \"Content-Length: {}\\r\\n\\r\\n{body}\", body.len()).unwrap();\n    }\n}\n\nstruct HttpResponse {\n    status: u16,\n    headers: HashMap<String, String>,\n}\n\nfn http_get(url: &Url) -> HttpResponse {\n    let host = url.host_str().unwrap();\n    let port = url.port_or_known_default().unwrap();\n    let mut stream = TcpStream::connect((host, port)).unwrap();\n    let target = match url.query() {\n        Some(query) => format!(\"{}?{}\", url.path(), query),\n        None => url.path().to_owned(),\n    };\n    write!(\n        stream,\n        \"GET {target} HTTP/1.1\\r\\nHost: {host}:{port}\\r\\nConnection: close\\r\\n\\r\\n\"\n    )\n    .unwrap();\n\n    let mut reader = BufReader::new(stream);\n    let mut status_line = String::new();\n    reader.read_line(&mut status_line).unwrap();\n    let status = status_line\n        .split(' ')\n        .nth(1)\n        .unwrap()\n        .trim()\n        .parse()\n        .unwrap();\n\n    let mut headers = HashMap::new();\n    loop {\n        let mut line = String::new();\n        reader.read_line(&mut line).unwrap();\n        let line = line.trim_end();\n        if line.is_empty() {\n            break;\n        }\n        if let Some((name, value)) = line.split_once(':') {\n            headers.insert(name.to_ascii_lowercase(), value.trim_start().to_owned());\n        }\n    }\n    HttpResponse { status, headers }\n}\n\n#[test]\nfn basic_request_token() {\n    let dir = TempDir::new().unwrap();\n    let readyp = dir.path().join(\"ready\");\n    let xdg_dir = dir.path().join(\"runtime\");\n    let configp = dir.path().join(\"pizauth.conf\");\n\n    let mut oauths = OAuthServer::start();\n    fs::write(&configp, pizauth_config(&oauths)).unwrap();\n\n    let _pizauths = PizauthServer::start(dir.path(), &xdg_dir, &configp, &readyp);\n\n    let show = pizauth_cmd(&xdg_dir, [\"show\", ACCOUNT]).output().unwrap();\n    assert!(!show.status.success());\n    let auth_url = pending_auth_url(&show);\n\n    let auth_response = http_get(&auth_url);\n    assert_eq!(auth_response.status, 302);\n    let redirect_url = auth_response\n        .headers\n        .get(\"location\")\n        .unwrap()\n        .parse::<Url>()\n        .unwrap();\n\n    let callback_response = http_get(&redirect_url);\n    assert_eq!(callback_response.status, 200);\n    oauths.join();\n\n    let show = pizauth_cmd(&xdg_dir, [\"show\", ACCOUNT]).output().unwrap();\n    assert!(\n        show.status.success(),\n        \"show failed: {}\",\n        String::from_utf8_lossy(&show.stderr)\n    );\n    assert_eq!(\n        String::from_utf8(show.stdout).unwrap(),\n        format!(\"{ACCESS_TOKEN}\\n\")\n    );\n}\n"
  }
]