Repository: ltratt/pizauth Branch: master Commit: 8f55ab2a994a Files: 41 Total size: 238.9 KB Directory structure: gitextract_s5a7sh2t/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGES.md ├── COPYRIGHT ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile ├── README.md ├── README.systemd.md ├── build.rs ├── examples/ │ ├── pizauth-state-custom.service │ ├── pizauth.conf │ └── systemd/ │ └── pizauth.conf ├── lib/ │ └── systemd/ │ └── user/ │ ├── pizauth-state-age.service │ ├── pizauth-state-creds.service │ ├── pizauth-state-gpg-passphrase.service │ ├── pizauth-state-gpg.service │ └── pizauth.service ├── pizauth.1 ├── pizauth.conf.5 ├── share/ │ ├── bash/ │ │ └── completion.bash │ ├── fish/ │ │ └── pizauth.fish │ └── zsh/ │ └── _pizauth ├── src/ │ ├── compat/ │ │ ├── daemon.rs │ │ └── mod.rs │ ├── config.l │ ├── config.rs │ ├── config.y │ ├── config_ast.rs │ ├── main.rs │ ├── server/ │ │ ├── eventer.rs │ │ ├── http_server.rs │ │ ├── mod.rs │ │ ├── notifier.rs │ │ ├── refresher.rs │ │ ├── request_token.rs │ │ └── state.rs │ ├── shell_cmd.rs │ └── user_sender.rs └── tests/ └── basic.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: jobs: rustfmt: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: rustup update stable && rustup default stable - run: rustup component add rustfmt - run: cargo fmt --all --check test: runs-on: ${{ matrix.os }} strategy: matrix: include: - name: Linux x86_64 stable os: ubuntu-latest rust: stable other: i686-unknown-linux-gnu - name: Linux x86_64 beta os: ubuntu-latest rust: beta other: i686-unknown-linux-gnu - name: Linux x86_64 nightly os: ubuntu-latest rust: nightly other: i686-unknown-linux-gnu - name: macOS x86_64 stable os: macos-latest rust: stable other: x86_64-apple-ios name: Tests ${{ matrix.name }} steps: - uses: actions/checkout@v3 - run: rustup update stable && rustup default stable - name: debug_tests run: cargo test - name: release_tests run: cargo test --release ================================================ FILE: .gitignore ================================================ target/ ================================================ FILE: CHANGES.md ================================================ # pizauth 1.1.0 (2026-XX-XX) * `auth_notify_cmd` is now subject to a 10 second timeout. If you previously relied on the command being run taking an arbitrary amount of time, you will need to run it to ensure it is not affected by the timeout. # pizauth 1.0.11 (2026-03-10) * Mark certain HTTP error codes as transitory, allowing pizauth to retry rather than immediately give up. # pizauth 1.0.10 (2026-02-25) * Remove `tmppath` pledge on OpenBSD. This doesn't do anything useful in pizauth, and support for it will be removed in OpenBSD. # pizauth 1.0.9 (2025-12-13) * Update dependencies with breaking changes. # pizauth 1.0.8 (2025-11-02) * Add `startup_cmd` configuration option: this shell command is run after pizauth's server is fully up and running. It can be used, for example, as a callback, safe in the knowledge that the server can respond. * Add Fish completion. # pizauth 1.0.7 (2025-02-08) * Add an upper limit to the size of HTTP requests pizauth accepts. In the (admittedly rather unlikely) case that a client malfunctions, this makes it more difficult for them to accidentally cause pizauth to run out of memory. * Wake up the refresher / notifier periodically (currently every 37 seconds) to see if there is work to do. This is a crude workaround for inconsistency in operating systems as to whether "wake up in X seconds" includes time spent in suspend/hibernate or adjusted by `adjtime` and so on. * Present times (e.g. about expired tokens) in a way that is less likely to be skewed by suspend/hibernate on some operating systems. # pizauth 1.0.6 (2024-11-10) * Support HTTPS redirects. pizauth now starts, by default, both HTTP and HTTPS servers, generating a new self-signed HTTPS certificate on each invocation. `pizauth info` shows you the certificate hash so you can verify that your browser is connecting to the right HTTPS server. You can turn either (but not both!) of the HTTP and HTTPS servers off with `http_listen=off` or `https_listen=off`. This is most useful if you want to force HTTPS redirects and ensure that you're not accidentally being redirected to an HTTP URL (i.e. `http_listen=off` is the option you are most likely to be interested in). # pizauth 1.0.5 (2024-07-27) * Use `XDG_RUNTIME_DIR` instead of `XDG_DATA_HOME` for the socket path. # pizauth 1.0.4 (2024-02-04) * Add `pizauth revoke` which revokes any tokens / ongoing authentication for a given account. Note that this does *not* revoke the remote token, as OAuth2 does not currently have standard support for this. * Include bash completion scripts and example systemd units. * Rework file installation to better handle a variety of OSes and file layouts. The Makefile is now only compatible with gmake. # pizauth 1.0.3 (2023-11-28) * Add `pizauth status` to see which accounts have valid tokens (or not): ``` $ pizauth status act1: No access token act2: Active access token (obtained Sat, 4 Nov 2023 21:52:11 +0000; expires Sat, 4 Nov 2023 23:20:42 +0000) act3: Active access token (obtained Sat, 4 Nov 2023 22:23:03 +0000; expires Mon, 4 Dec 2023 22:23:03 +0000) ``` * Give better syntax errors if backslashes (`\\`) are used incorrectly. # pizauth 1.0.2 (2023-10-12) * Better handle syntactically invalid requests. In particular, this causes the check for a running instance of pizauth not to cause the existing instance to exit. * Document `-v` (which can be repeated up to 4 times for increased verbosity) on `pizauth server`. # pizauth 1.0.1 (2023-08-14) * Fix location of `ring` dependency. # pizauth 1.0.0 (2023-08-13) * First stable release. * Add `pizauth info [-j]` which informs the user where pizauth is looking for configuration files and so on. For example: ``` $ pizauth info pizauth version 0.3.0: cache directory: /home/ltratt/.cache/pizauth config file: /home/ltratt/.config/pizauth.conf ``` Adding `-j` outputs the same information in JSON format for integration with external tools. The `info_format_version` field is an integer value specifying the version of the JSON output: if incompatible changes are made, this integer will be monotonically increased. # pizauth 0.3.0 (2023-05-29) ## Breaking changes * `not_transient_error_if` has been removed as a global and per-account option. In its place is a new global option `transient_error_if_cmd`. If you have an existing `not_transient_error_if` option, you will need to reconsider the shell command you execute. One possibility is to change: ``` not_transient_error_if = "shell-cmd"; ``` to: ``` transient_error_if_cmd = "! shell-cmd"; ``` `transient_error_if_cmd` sets the environment variable `$PIZAUTH_ACCOUNT` to allow you to perform different actions for different accounts if you desire. # pizauth 0.2.2 (2023-04-03) * Added a global `token_event_cmd` option, which runs a command whenever an account's token changes state. * Added `pizauth dump` and `pizauth restore` which dump and restore pizauth's token state respectively. When combined with `token_event_cmd`, these allow users to persist token state. The output from `pizauth dump` is not meaningfully encrypted: it is the user's responsibility to encrypt, or otherwise secure, the dump output. * Update an account's refresh token if it is sent to pizauth by the remote server. * Move from the unmaintained `json` to the `serde_json` crate. * Don't bother users with OAuth2 "errors" (e.g. that a token cannot be refreshed) that are better thought of as an inevitable part of the OAuth2 lifecycle. # pizauth 0.2.1 (2023-03-11) * `login_hint` is now deprecated in favour of the more general `auth_uri_fields`. Change: ``` login_hint = "email@example.com"; ``` to: ``` auth_uri_fields = { "login_hint": "email@example.com" }; ``` Currently `login_hint` is silently transformed into the equivalent `auth_uri_fields` for backwards compatibility. * `auth_uri_fields` allows users to specify zero or more key/value pairs to be appended to the authorisation URI. Keys (and their values) are appended in the order they appear in `auth_uri_fields`, each separated by a `&`. The same key may be specified multiple times. * Several options can now be set globally and overridden in individual accounts: * `not_transient_error_if` * `refresh_at_least` * `refresh_before_expiry` * `refresh_retry` * `scopes` is now optional and also, equivalently, can be empty. # pizauth 0.2.0 (2022-12-14) ## Breaking changes * `auth_error_cmd` has been renamed to `error_notify_cmd`. pizauth detects the (now) incorrect usage and informs the user. * `refresh_retry_interval` has been renamed to `refresh_retry` and moved from a global to a per-account option. Its default value remains unchanged. ## Other changes * Tease out transitory / permanent refresh errors. Transitory errors are likely to be the result of temporary network problems and simply waiting for them to resolve is normally the best thing to do. By default, pizauth thus simply ignores transitory errors. Users who wish to check that transitory errors really are transitory can set `not_transitory_error_if` setting. This is a shell command that, if it returns a zero exit code, signifies that transitory errors are permanent and that an access token should be invalidated. `nc -z ` is an example of a reasonable setting. `not_transitory_error_if` is fail-safe in that if the shell command fails unnecessarily (e.g. if you specify `ping` on a network that prevents ping traffic), pizauth will invalidate the access token. * Each refresh of an account now happens in a separate thread, so stalled refreshing cannot affect other accounts. * Fix bug where newly authorised access tokens were immediately refreshed. # pizauth 0.1.1 (2022-10-20) Second alpha release. * Added global `http_listen` option to fix the IP address and port pizauth's HTTP server listens on. This is particularly useful when running pizauth on a remote machine, since it makes it easy to open an `ssh -L` tunnel to authenticate that remote instance. * Fix build on OS X by ignoring deprecation warnings for the `daemon` function. * Report config errors before daemonisation. # pizauth 0.1.0 (2022-09-29) First alpha release. ================================================ FILE: COPYRIGHT ================================================ Except as otherwise noted (below and/or in individual files), this project is licensed under the Apache License, Version 2.0 or the MIT license , at your option. Copyright is retained by contributors and/or the organisations they represent(ed) -- this project does not require copyright assignment. Please see version control history for a full list of contributors. Note that some files may include explicit copyright and/or licensing notices. ================================================ FILE: Cargo.toml ================================================ [package] name = "pizauth" description = "Command-line OAuth2 authentication daemon" version = "1.0.11" repository = "https://github.com/ltratt/pizauth/" authors = ["Laurence Tratt "] readme = "README.md" license = "Apache-2.0 OR MIT" categories = ["authentication"] keywords = ["oauth", "oauth2", "authentication"] edition = "2021" [build-dependencies] cfgrammar = "0.14" lrlex = "0.14" lrpar = "0.14" rerun_except = "1" [dependencies] base64 = "0.22" boot-time = "0.1.2" cfgrammar = "0.14" chacha20poly1305 = "0.10" chrono = "0.4" getopts = "0.2" hostname = "0.4" log = "0.4" lrlex = "0.14" lrpar = "0.14" nix = { version="0.31.2", features=["fs", "signal"] } rand = "0.10.1" serde = { version="1.0", features=["derive"] } sha2 = "0.11.0" serde_json = "1" stderrlog = "0.6" syslog = "7.0.0" ureq = "3" url = "2" wait-timeout = "0.2" whoami = "2.1.2" rustls = { version = "0.23.12", features = ["ring", "std"], default-features = false } rcgen = { version = "0.14.5", features = ["crypto", "ring"], default-features = false } wincode = { version = "0.5.3", features = ["derive"] } [target.'cfg(target_os="openbsd")'.dependencies] pledge = "0.4" unveil = "0.3" [target.'cfg(target_os="macos")'.dependencies] libc = "0.2" [dev-dependencies] tempfile = "3.27.0" [profile.release] opt-level = 3 debug = false rpath = false lto = true debug-assertions = false codegen-units = 1 panic = 'abort' incremental = false overflow-checks = true ================================================ FILE: LICENSE-APACHE ================================================ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: LICENSE-MIT ================================================ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ PREFIX ?= /usr/local BINDIR ?= ${PREFIX}/bin LIBDIR ?= ${PREFIX}/lib SHAREDIR ?= ${PREFIX}/share EXAMPLESDIR ?= ${SHAREDIR}/examples MANDIR.${PREFIX} = ${PREFIX}/share/man MANDIR./usr/local = /usr/local/man MANDIR. = /usr/share/man MANDIR ?= ${MANDIR.${PREFIX}} .PHONY: all install test distrib all: target/release/pizauth target/release/pizauth: cargo build --release RUNNINGSYSTEMD=$(shell test -d /run/systemd/system/ && echo yes || echo no) ifeq ($(USESYSTEMD), 0) INSTALLSYSTEMD := else ifneq ($(RUNNINGSYSTEMD), yes) INSTALLSYSTEMD := else INSTALLSYSTEMD := install-systemd endif install: target/release/pizauth ${INSTALLSYSTEMD} install -d ${DESTDIR}${BINDIR} install -c -m 555 target/release/pizauth ${DESTDIR}${BINDIR}/pizauth install -d ${DESTDIR}${MANDIR}/man1 install -d ${DESTDIR}${MANDIR}/man5 install -c -m 444 pizauth.1 ${DESTDIR}${MANDIR}/man1/pizauth.1 install -c -m 444 pizauth.conf.5 ${DESTDIR}${MANDIR}/man5/pizauth.conf.5 install -d ${DESTDIR}${EXAMPLESDIR}/pizauth install -c -m 444 examples/pizauth.conf ${DESTDIR}${EXAMPLESDIR}/pizauth/pizauth.conf install -d ${DESTDIR}${SHAREDIR}/bash-completion/completions install -c -m 444 share/bash/completion.bash ${DESTDIR}${SHAREDIR}/bash-completion/completions/pizauth install -d ${DESTDIR}${SHAREDIR}/fish/vendor_completions.d install -c -m 444 share/fish/pizauth.fish ${DESTDIR}${SHAREDIR}/fish/vendor_completions.d install -d ${DESTDIR}${SHAREDIR}/zsh/site-functions install -c -m 444 share/zsh/_pizauth ${DESTDIR}${SHAREDIR}/zsh/site-functions/_pizauth install-systemd: install -d ${DESTDIR}${LIBDIR}/systemd/user install -c -m 444 lib/systemd/user/pizauth.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth.service install -c -m 444 lib/systemd/user/pizauth-state-creds.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-creds.service install -c -m 444 lib/systemd/user/pizauth-state-age.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-age.service install -c -m 444 lib/systemd/user/pizauth-state-gpg.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-gpg.service install -c -m 444 lib/systemd/user/pizauth-state-gpg-passphrase.service ${DESTDIR}${LIBDIR}/systemd/user/pizauth-state-gpg-passphrase.service install -d ${DESTDIR}${EXAMPLESDIR}/pizauth install -c -m 444 examples/pizauth-state-custom.service ${DESTDIR}${EXAMPLESDIR}/pizauth/pizauth-state-custom.service test: cargo test cargo test --release distrib: test "X`git status --porcelain`" = "X" @read v?'pizauth version: ' \ && mkdir pizauth-$$v \ && cp -rp Makefile build.rs Cargo.lock Cargo.toml \ COPYRIGHT LICENSE-APACHE LICENSE-MIT \ CHANGES.md README.md README.systemd.md \ pizauth.1 pizauth.conf.5 \ examples lib share src \ pizauth-$$v \ && tar cfz pizauth-$$v.tgz pizauth-$$v \ && rm -rf pizauth-$$v ================================================ FILE: README.md ================================================ # pizauth: an OAuth2 token requester daemon pizauth is a simple program for requesting, showing, and refreshing OAuth2 access tokens. pizauth is formed of two components: a persistent server which interacts with the user to request tokens, and refreshes them as necessary; and a command-line interface which can be used by programs such as [fdm](https://github.com/nicm/fdm) and [msmtp](https://marlam.de/msmtp/) to authenticate with OAuth2. ## Quick setup pizauth's configuration file is `~/.config/pizauth.conf`. You need to specify at least one `account`, which tells pizauth how to authenticate against a particular OAuth2 setup. Most users will also want to receive asynchronous notifications of authorisation requests and errors, which requires setting `auth_notify_cmd` and `error_notify_cmd`. See [the bundled example configuration](examples/pizauth.conf) for more details. ### Account setup At a minimum you need to find out from your provider: * The authorisation URI. * The token URI. * Your "Client ID" (and in many cases also your "Client secret"), which identify your software. * (In some cases) The scope(s) which your OAuth2 access token will give you access to. For pizauth to be able to refresh tokens, you may need to add an explicit `offline_access` scope. * (In some cases) The redirect URI (you must copy this *exactly*, including trailing slash `/` characters). The default value of `http://localhost/` suffices in most instances. Some providers allow you to create Client IDs and Client Secrets at will (e.g. [Google](https://console.developers.google.com/projectselector/apis/credentials)). Some providers sometimes allow you to create Client IDs and Client Secrets (e.g. [Microsoft Azure](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app) but allow organisations to turn off this functionality. For example, to create an account called `officesmtp` which obtains OAuth2 tokens which allow you to read email via IMAP and send email via Office365's servers: ``` account "officesmtp" { auth_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; token_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; client_id = "..."; // Fill in with your Client ID client_secret = "..."; // Fill in with your Client secret scopes = [ "https://outlook.office365.com/IMAP.AccessAsUser.All", "https://outlook.office365.com/SMTP.Send", "offline_access" ]; // You don't have to specify login_hint, but it does make authentication a // little easier. auth_uri_fields = { "login_hint": "email@example.com" }; } ``` ### Notifications As standard, pizauth displays authorisation URLs and errors on stderr. If you want to use pizauth in the background, it is easy to miss such output. Fortunately, pizauth can run arbitrary commands to alert you that you need to authorise a new token, in essence giving you the ability to asynchronously display notifications. There are two main settings: * `auth_notify_cmd` notifies users that an account needs authenticating. The command is run with two environment variables set: * `PIZAUTH_ACCOUNT` is set to the account name to be authorised. * `PIZAUTH_URL` is set to the authorisation URL. * `error_notify_cmd` notifies users of errors. The command is run with two environment variables set: * `PIZAUTH_ACCOUNT` is set to the account name to be authorised. * `PIZAUTH_MSG` is set to the error message. For example to use pizauth with `notify-send`: ``` auth_notify_cmd = "if [[ \"$(notify-send -A \"Open $PIZAUTH_ACCOUNT\" -t 30000 'pizauth authorisation')\" == \"0\" ]]; then open \"$PIZAUTH_URL\"; fi"; error_notify_cmd = "notify-send -t 90000 \"pizauth error for $PIZAUTH_ACCOUNT\" \"$PIZAUTH_MSG\""; ``` In this example, `notify-send` is used to open a notification with a "Open <account>" button; if that button is clicked, then the authorisation URL is opened in the user's default web browser. ### Running pizauth You need to start the pizauth server (alternatively, start `pizauth.service`, see [systemd-unit](README.systemd.md#systemd-unit)): ```sh $ pizauth server ``` and configure software to request OAuth2 tokens with `pizauth show officesmtp`. The first time that `pizauth show officesmtp` is executed, it will print an error to stderr that includes an authorisation URL (and, if `auth_notify_cmd` is set, it will also execute that command): ``` $ pizauth show officesmtp ERROR - 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=&redirect_uri=http%3A%2F%2Flocalhost%3A14204%2F&response_type=code&state=%25E6%25A0%25EF%2503h6%25BCK&client_secret=&login_hint=email@example.com ``` The user then needs to open that URL in the browser of their choice and complete authentication. Once complete, pizauth will be notified, and shortly afterwards `pizauth show officesmtp` will start showing a token on stdout: ``` $ pizauth show officesmtp DIASSPt7jlcBPTWUUCtXMWtj9TlPC6U3P3aV6C9NYrQyrhZ9L2LhyJKgl5MP7YV4 ``` Note that: 1. `pizauth show` does not block: if a token is not available it will fail; once a token is available it will succeed. 2. `pizauth show` can print OAuth2 tokens which are no longer valid. By default, pizauth will continually refresh your token, but it may eventually become invalid. There will be a delay between the token becoming invalid and pizauth realising that has happened and notifying you to request a new token. ## Command-line interface pizauth's usage is: ``` pizauth dump pizauth refresh [-u] pizauth reload pizauth restore pizauth server [-c ] [-d] pizauth show [-u] pizauth shutdown ``` Where: * `pizauth refresh` tries to obtain a new access token for an account. If an access token already exists, a refresh is tried; if an access token doesn't exist, a new request is made. * `pizauth reload` causes the server to reload its configuration (this is a safe equivalent of the traditional `SIGHUP` mechanism). * `pizauth server` starts a new instance of the server. * `pizauth show` displays an access token, if one exists, for `account`. If an access token does not exist, a new request is initiated. * `pizauth shutdown` asks the server to shut itself down. `pizauth dump` and `pizauth restore` are explained in the [Persistence](#persistence) section below. ## Example integrations Once you have set up pizauth, you will then need to set up the software which needs access tokens. This section contains example configuration snippets to help you get up and running. In these examples, text in chevrons (like ``) needs to be edited to match your individual setup. The examples assume that `pizauth` is in your `$PATH`: if it is not, you will need to substitute an absolute path to `pizauth` in these snippets. ### msmtp In your configuration file (typically `~/.config/msmtp/config`): ``` account auth xoauth2 host protocol smtp from user passwordeval pizauth show ``` ### mbsync Ensure you have the xoauth2 plugin for cyrus-sasl installed, and then use something like this for the IMAP account in `~/.mbsyncrc`: ``` IMAPAccount Host User PassCmd "pizauth show " AuthMechs XOAUTH2 ``` ## Example account settings Each provider you wish to authenticate with will have its own settings it requires of you. These can be difficult to find, so examples are provided in this section. Caveat emptor: these settings will not work in all situations, and providers have historically required users to intermittently change their settings. ### Microsoft / Exchange You may need to create your own client ID and secret by registering with Microsoft's [identity platform](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app). ``` account "" { auth_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; auth_uri_fields = { "login_hint": "" }; token_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; client_id = ""; client_secret = ""; scopes = [ "https://outlook.office365.com/IMAP.AccessAsUser.All", "https://outlook.office365.com/POP.AccessAsUser.All", "https://outlook.office365.com/SMTP.Send", "offline_access" ]; } ``` ### Gmail You may need to create your own client ID and secret via the [credentials tab](https://console.cloud.google.com/apis/credentials/oauthclient/) of the Google Cloud Console. ``` account "" { auth_uri = "https://accounts.google.com/o/oauth2/auth"; auth_uri_fields = {"login_hint": ""}; token_uri = "https://oauth2.googleapis.com/token"; client_id = ""; client_secret = ""; scopes = ["https://mail.google.com/"]; } ``` ### Miele You may need to create your own client ID and secret via the [get involved tab](https://www.miele.com/f/com/en/register_api.aspx) of the Miele Developer site. No scopes are needed. ``` account "" { auth_uri = "https://api.mcs3.miele.com/thirdparty/login/"; token_uri = "https://api.mcs3.miele.com/thirdparty/token/"; client_id = ""; client_secret = ""; } ``` ## pizauth on a remote machine You can run pizauth on a remote machine and have your local machine authenticate that remote existence with `ssh -L`. pizauth contains a small HTTP server used to receive authentication requests. By default the HTTP server listens on a random port, but it is easiest in this scenario to fix a port with the global `http_listen` option: ``` http_listen = "127.0.0.1:"; account "..." { ... } ``` Then on your local machine (using the same `` as above run `ssh`: ``` ssh -L 127.0.0.1::127.0.0.1: ``` Then on the remote machine start `pizauth server` and then `pizauth show `. Copy the authentication URL into a browser on your local machine and continue as normal. When you see the "pizauth processing authentication: you can safely close this page." message you can close the `ssh` tunnel. If the account later needs reauthenticating (e.g. because the refresh token has become invalid), simply reopen the `ssh` tunnel, reauthenticate, and close the `ssh` tunnel. ## Persistence By design, pizauth stores tokens state only in memory, and never to disk: users never have to worry that unencrypted secrets may be accessible on disk. However, if you use pizauth on a machine where pizauth is regularly restarted (e.g. because the machine is regularly rebooted), reauthenticating each time can be frustrating. `pizauth dump` (which writes pizauth's internal token state to `stdout`) and `pizauth restore` (which restores previously dumped token state read from `stdin`) allow you to persist state, but since they contain secrets they inevitably increase your security responsibilities. Although the output from `pizauth dump` may look like it is encrypted, it is trivial for an attacker to recover secrets from it: it is strongly recommended that you immediately encrypt the output from `pizauth dump` to avoid possible security issues. The most common way to call `pizauth dump` is via the `token_event_cmd` configuration setting. `token_event_cmd` is called each time an account's tokens change state (e.g. new tokens, refreshed tokens, etc). You can use this to run an arbitrary shell command such as `pizauth dump`: ``` token_event_cmd = "pizauth dump | age --encrypt --output pizauth.age -R age_public_key"; ``` In this example, output from `pizauth dump` is immediately encrypted using [age](https://age-encryption.org/). In your machine's startup process you can then call `pizauth restore` to restore the most recent dump e.g.: ``` age --decrypt -i age_private_key -o - pizauth.age | pizauth restore ``` Note that `pizauth restore` does not change the running pizauth's configuration. Any changes in security relevant configuration between the dumping and restoring pizauth instances cause those parts of the dump to be silently ignored. ## Alternatives pizauth will not be perfect for everyone. You may also wish to consider these programs as alternatives: * [Email OAuth 2.0 Proxy](https://github.com/simonrob/email-oauth2-proxy) * [mailctl](https://github.com/pdobsan/mailctl) * [mutt_oauth2.py](https://gitlab.com/muttmua/mutt/-/blob/master/contrib/mutt_oauth2.py) * [oauth-helper-office-365](https://github.com/ahrex/oauth-helper-office-365) ================================================ FILE: README.systemd.md ================================================ # Systemd unit Pizauth comes with a systemd unit. In order for it to communicate properly with `systemd`, your `startup_cmd` in `pizauth.conf` must at some point run `systemd-notify --ready --pid=parent` -- this will tell `systemd` that `pizauth` has started up. To start pizauth: ```sh $ systemctl --user start pizauth.service ``` If you want `pizauth` to start on login, run ```sh $ systemctl --user enable pizauth.service ``` (pass `--now` to also start `pizauth` with this invocation) If you want to save pizauth's dumps encrypted and automatically restore them when pizauth is started, you need to start/enable one of the `pizauth-state-*.service` files provided by pizauth. For example, ```sh $ systemctl --user enable pizauth-state-creds.service ``` Some of these units require further configuration, eg for setting the public key and location of the private key to use for encryption. For this purpose, ```sh $ systemctl --user edit pizauth-state-$METHOD.service ``` will open an editor in which you can configure your local edits to `pizauth-state-$METHOD.service`. For example, you can override the default location of the pizauth dumps (`$XDG_STATE_HOME/pizauth-state-$METHOD.dump`) to be `~/.pizauth.dump` by inserting the following line in the `.conf` file that `systemctl` will open: ```ini Environment="PIZAUTH_STATE_FILE=%h/.pizauth.dump ``` See `systemd.unit(5)` for supported values of these % "specifiers". The provided configurations are: - `pizauth-state-creds.service`: Uses `systemd-creds` to encrypt the dumps with some combination of your device's TPM2 chip and a secret accessible only to `root`. This means the dumps generally can only be decrypted *on the device that encrypted them*. - `pizauth-state-age.service`: Uses `age` to encrypt the dumps. Needs the `Environment="PIZAUTH_KEY_ID="` line to be set to the public key to encrypt with. - `pizauth-state-gpg.service`: Uses `gpg` to encrypt the dumps. Needs the `Environment="PIZAUTH_KEY_ID="` line to be set to the public key to encrypt with. `gpg-agent` will prompt for the passphrase to unlock the key, which may be undesireable in nongraphical environments. - `pizauth-state-gpg-passphrase.service`: Uses `gpg` to encrypt the dumps. Uses `systemd-creds` to encrypt a file containing the passphrase, which is set by default to be `$XDG_CONFIG_HOME/pizauth-state-gpg-passphrase.cred`. Needs the `Environment="PIZAUTH_KEY_ID="` line to be set to the public key to encrypt with. Also needs the passphrase to be stored encrypted somewhere, see the unit file for details. Note: Given the security implications here, this method is likely not much more secure than just using `pizauth-state-creds.service` directly. This unit is provided mostly to document how one might go about automatically passing key material relatively safely to a unit. ================================================ FILE: build.rs ================================================ use cfgrammar::yacc::YaccKind; use lrlex::{CTLexerBuilder, DefaultLexerTypes}; use rerun_except::rerun_except; fn main() -> Result<(), Box> { rerun_except(&[ "CHANGES.md", "LICENSE-APACHE", "LICENSE-MIT", "pizauth.1", "pizauth.conf.5", "pizauth.conf.example", "README.md", ])?; CTLexerBuilder::>::new_with_lexemet() .lrpar_config(|ctp| { ctp.yacckind(YaccKind::Grmtools) .grammar_in_src_dir("config.y") .unwrap() }) .lexer_in_src_dir("config.l")? .build()?; Ok(()) } ================================================ FILE: examples/pizauth-state-custom.service ================================================ # In case the supplied pizauth-state-*.service files don't suit your needs, # this is the template for a new pizauth-state-*.service file. # See systemd.service(5), systemd.unit(5), systemd.exec(5) for more details. # # We pull out the dump/restore feature as its own unit since under the systemd # semantics, it makes more sense -- as indicated by the fact that were we to # try to configure this directly in the pizauth unit, we'd need to prepend to # ExecStop. Moreover, pizauth *works* without dump/restore -- this is an # additional feature on top of it. # # Hence, we have a separate dump/restore unit that gets started after pizauth # and torn down before it, and which is responsible for managing the state file # upon these events. [Unit] Description=Custom pizauth dump/restore backend # Makes the start event for this unit propagate to pizauth, # and makes the stop/abort events for pizauth propagate to this unit BindsTo=pizauth.service # Orders this unit to run its start commands after pizauth, and run its stop # commands before pizauth After=pizauth.service # Makes the config-reload event for pizauth propagate to this unit ReloadPropagatedFrom=pizauth.service [Service] Type=simple Environment="PIZAUTH_STATE_FILE=%S/%N.dump" # replace io by whatever program you have to read/write to the state file # (could be encryption software, could be sending/retrieving to a server, etc) ExecStart=-sh -c 'io --read "$PIZAUTH_STATE_FILE" | pizauth restore' ExecReload=-sh -c 'io --read "$PIZAUTH_STATE_FILE" | pizauth restore' ExecStop=-sh -c 'pizauth dump | io --write "$PIZAUTH_STATE_FILE"' [Install] # Makes systemctl --user enable pizauth-state-custom.service cause that the # start event for pizauth will propagate to this unit WantedBy=pizauth.service ================================================ FILE: examples/pizauth.conf ================================================ account "officesmtp" { auth_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; token_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; client_id = "..."; // Fill in with your Client ID client_secret = "..."; // Fill in with your Client secret scopes = [ "https://outlook.office365.com/IMAP.AccessAsUser.All", "https://outlook.office365.com/SMTP.Send", "offline_access" ]; // You don't have to specify login_hint, but it does make // authentication a little easier. auth_uri_fields = { "login_hint": "email@example.com" }; } ================================================ FILE: examples/systemd/pizauth.conf ================================================ // If using systemd, comment out the following line // startup_cmd="systemd-notify --ready --pid=parent"; // If using the pizauth-state-*.service units to save the state, // the following may be useful -- it will trigger a save/restore of the state // upon each token state change (set METHOD to whatever storage method you're // using) // token_event_cmd="systemctl --user restart pizauth-state-METHOD.service"; ================================================ FILE: lib/systemd/user/pizauth-state-age.service ================================================ [Unit] Description=pizauth dump/restore backend (encryption: age) BindsTo=pizauth.service After=pizauth.service ReloadPropagatedFrom=pizauth.service [Service] Type=simple RemainAfterExit=yes # This unit needs to be configured before it's usable! # To use this unit, use systemctl --user edit pizauth-state-age.service to # create a drop-in configuration file. In it, set # Environment="PIZAUTH_KEY_ID=public key you want to encrypt with" # Environment="PIZAUTH_KEY_FILE=path to file Environment="PIZAUTH_KEY_ID=" Environment="PIZAUTH_KEY_FILE=" Environment="PIZAUTH_STATE_FILE=%S/%N.dump" ExecStart=-sh -c 'age --decrypt --identity "$PIZAUTH_KEY_FILE" -o - "$PIZAUTH_STATE_FILE" | pizauth restore' ExecReload=-sh -c 'age --decrypt --identity "$PIZAUTH_KEY_FILE" -o - "$PIZAUTH_STATE_FILE" | pizauth restore' ExecStop=-sh -c 'pizauth dump | age --encrypt --recipient "$PIZAUTH_KEY_ID" -o "$PIZAUTH_STATE_FILE"' [Install] WantedBy=pizauth.service ================================================ FILE: lib/systemd/user/pizauth-state-creds.service ================================================ [Unit] Description=pizauth dump/restore backend (encryption: systemd-creds) BindsTo=pizauth.service After=pizauth.service ReloadPropagatedFrom=pizauth.service [Service] Type=simple RemainAfterExit=yes Environment="PIZAUTH_STATE_FILE=%S/%N.dump" ExecStart=-sh -c 'systemd-creds --user decrypt $PIZAUTH_STATE_FILE | pizauth restore' ExecReload=-sh -c 'systemd-creds --user decrypt $PIZAUTH_STATE_FILE | pizauth restore' ExecStop=-sh -c 'pizauth dump | systemd-creds --user encrypt - $PIZAUTH_STATE_FILE' [Install] WantedBy=pizauth.service ================================================ FILE: lib/systemd/user/pizauth-state-gpg-passphrase.service ================================================ [Unit] Description=pizauth dump/restore backend (encryption: gpg, passphrase in systemd-creds) BindsTo=pizauth.service After=pizauth.service ReloadPropagatedFrom=pizauth.service Requires=gpg-agent.socket [Service] # Since we're using credentials, we can't use Type=simple Type=exec RemainAfterExit=yes # This unit needs to be configured before it's usable! # To use this unit, use systemctl --user edit pizauth-state-gpg.service to # create a drop-in configuration file. In it, set # Environment="PIZAUTH_KEY_ID=public key you want to encrypt with" # and then either # LoadCredentialEncrypted=pizauth-gpg-passphrase:CREDENTIALFILE # or # SetCredentialEncrypted=pizauth-gpg-passphrase: \ # ..................................................................... \ # ... # # In either case, you will need to store the passphrase for the GPG key # encrypted. If you plan on storing the credential at CREDENTIALFILE, run # systemd-ask-password \ # | systemd-creds encrypt --name pizauth-gpg-passphrase - CREDENTIALFILE # If you want to store the credential in the drop-in configuration, run # systemd-ask-password \ # | systemd-creds encrypt --name pizauth-gpg-passphrase -p - - # This will print the SetCredentialEncrypted config you'll need to paste in the # drop-in configuration Environment="PIZAUTH_KEY_ID=" Environment="PIZAUTH_STATE_FILE=%S/%N.dump" ExecStart=-sh -c ' gpg --passphrase-file %d/pizauth-gpg-passphrase --pinentry-mode loopback --batch --decrypt "$PIZAUTH_STATE_FILE" \ | pizauth restore' ExecReload=-sh -c ' gpg --passphrase-file %d/pizauth-gpg-passphrase --pinentry-mode loopback --batch --decrypt "$PIZAUTH_STATE_FILE" \ | pizauth restore' ExecStop=-sh -c 'pizauth dump | gpg --batch --yes --encrypt --recipient $PIZAUTH_KEY_ID -o "$PIZAUTH_STATE_FILE"' [Install] WantedBy=pizauth.service ================================================ FILE: lib/systemd/user/pizauth-state-gpg.service ================================================ [Unit] Description=pizauth dump/restore backend (encryption: gpg) BindsTo=pizauth.service After=pizauth.service ReloadPropagatedFrom=pizauth.service Requires=gpg-agent.socket [Service] Type=simple RemainAfterExit=yes # This unit needs to be configured before it's usable! # To use this unit, use systemctl --user edit pizauth-state-age.service to # create a drop-in configuration file. In it, set # Environment="PIZAUTH_KEY_ID=public key you want to encrypt with" Environment="PIZAUTH_KEY_ID=" Environment="PIZAUTH_STATE_FILE=%S/%N.dump" ExecStart=-sh -c 'gpg --batch --decrypt "$PIZAUTH_STATE_FILE" | pizauth restore' ExecReload=-sh -c 'gpg --batch --decrypt "$PIZAUTH_STATE_FILE" | pizauth restore' ExecStop=-sh -c 'pizauth dump | gpg --batch --yes --encrypt --recipient $PIZAUTH_KEY_ID -o "$PIZAUTH_STATE_FILE"' [Install] WantedBy=pizauth.service ================================================ FILE: lib/systemd/user/pizauth.service ================================================ [Unit] Description=Pizauth OAuth2 token manager Documentation=man:pizauth(1) man:pizauth.conf(5) Documentation=https://github.com/ltratt/pizauth/blob/master/README.md Documentation=https://github.com/ltratt/pizauth/blob/master/README.systemd.md [Service] Type=notify # Allow all processes in the cgroup to set the service status, needed to be able # to set status via eg startup_cmd, token_event_cmd, etc NotifyAccess=all ExecStart=/usr/bin/pizauth server -vvvv -d ExecReload=/usr/bin/pizauth reload ExecStop=/usr/bin/pizauth shutdown [Install] WantedBy=default.target ================================================ FILE: pizauth.1 ================================================ .Dd $Mdocdate: September 13 2022 $ .Dt PIZAUTH 1 .Os .Sh NAME .Nm pizauth .Nd OAuth2 authentication daemon .Sh SYNOPSIS .Nm pizauth .Sy Em command .Sh DESCRIPTION .Nm requests, shows, and refreshes OAuth2 tokens. It is formed of two components: a persistent "server" which interacts with the user to obtain tokens, and refreshes them as necessary; and a command-line interface which can be used by other programs to show the OAuth2 token for a current account. .Pp The top-level commands are: .Bl -tag -width Ds .It Sy dump Writes the current .Nm state to stdout: this can later be fed back into .Nm with .Sy restore . The dump format is stable within a pizauth major release (but not across major releases) and stable across platforms, though it includes timestamps that may be affected by clock drift on either the machine performing .Sy dump or .Sy restore . Clock drift does not not affect security, though it may cause dumped access tokens to be refreshed unduly early or late upon a .Sy restore . Refreshed access tokens will then be refreshed at the expected intervals. .Pp Note that while the .Sy dump output may look like it is encrypted, it is trivial for an attacker to recover access and refresh tokens from it: it is strongly recommended that you use external encryption on the output so that your data cannot be compromised. .It Sy info Oo Fl j Oc Writes output about .Nm to stdout including: the cache directory path; the config file path; and .Nm version. Defaults to human-readable output in an unspecified format that may change freely between .Nm versions. .Pp .Fl j specifies JSON output. The .Qq info_format_version field is an integer value specifying the version of the JSON output: if incompatible changes are made, this integer will be monotonically increased. .It Sy refresh Oo Fl u Oc Ar account Request a refresh of the access token for .Em account . Exits with 0 upon success. If there is not currently a valid access or refresh token, reports an error to stderr, initiates a new token request, and exits with 1. Unless .Fl u is specified, the error will include an authorization URL. Note that this command does not block and will not start a new refresh if one is ongoing. .It Sy reload Reload the server's configuration. Exits with 0 upon success or 1 if there is a problem in the configuration. .It Sy restore Reads previously dumped .Nm state from stdin and updates those parts of the current state it determines to be less useful than the dumped state. This does not change the running instance's configuration: any changes in security relevant configuration between the dumping and restoring .Nm instances causes those parts of the dump to be silently ignored. See .Sy dump for information about the dump format, timestamp warnings, and encryption suggestions. .It Sy revoke Ar account Removes any token, and cancels any ongoing authentication, for .Em account . Note that OAuth2 provides no standard way of remotely revoking a token: .Sy revoke thus only affects the local .Nm instance. Exits with 0 upon success. .It Sy server Oo Fl c Ar config-file Oc Oo Fl dv Oc Start the server. If not specified with .Fl c , .Nm checks for the configuration file (in order) at: .Pa $XDG_CONFIG_HOME/pizauth.conf , .Pa $HOME/.config/pizauth.conf . The server will daemonise itself unless .Fl d is specified. Exits with 0 if the server started successfully or 1 otherwise. .Fl v enables more verbose logging. .Fl v can be used up to 4 times, with each repetition increasing the quantity of logging. .It Sy show Oo Fl u Oc Ar account If there is an access token for .Em account , print that access token to stdout and exit with 0. If there is not currently a valid access token, prints an error to stderr and exits with 1. If refreshing might obtain a valid access token, refreshing is initiated in the background. Otherwise (unless .Fl u is specified), the error will include an authorization URL. Note that this command does not block: commands must expect that they might encounter an error when showing an access token. .It Sy shutdown Shut the server down. Note that shutdown occurs asynchronously: the server may still be alive for a period of time after this command returns. .It Sy status Writes output about the current accounts and whether they have access tokens to stdout. The format is human-readable and in an unspecified format that may change freely between .Nm versions. .El .Sh SEE ALSO .Xr pizauth.conf 5 .Pp .Lk https://tratt.net/laurie/src/pizauth/ .Sh AUTHORS .An -nosplit .Nm was written by .An Laurence Tratt Lk https://tratt.net/laurie/ ================================================ FILE: pizauth.conf.5 ================================================ .Dd $Mdocdate: September 13 2022 $ .Dt PIZAUTH.CONF 5 .Os .Sh NAME .Nm pizauth.conf .Nd pizauth configuration file .Sh DESCRIPTION .Nm is the configuration file for .Xr pizauth 1 . .Pp The top-level options are: .Bl -tag -width Ds .It Sy auth_notify_cmd = Qo Em shell-cmd Qc ; specifies a shell command to be run via .Ql $SHELL -c when an account needs to be authenticated. Two special environment variables are set: .Em $PIZAUTH_ACCOUNT is set to the account name; .Em $PIZAUTH_URL is set to the URL required to authorise the account. Note that .Sy auth_event_cmd is subject to a 10 second timeout. Optional. Optional. .It Sy auth_notify_interval = Em time ; specifies the gap between reminders to the user of authentication requests. Defaults to 15 minutes if not specified. .It Sy error_notify_cmd = Qo Em shell-cmd Qc ; specifies a shell command to be run via .Ql $SHELL -c when an error has occurred when authenticating an account. Two special environment variables are set: .Em $PIZAUTH_ACCOUNT is set to the account name; .Em $PIZAUTH_MSG is set to the error message. Defaults to logging via .Xr syslog 3 if not specified. .It Sy http_listen = Em none | Qo Em bind-name Qc ; specifies the address for the .Xr pizauth 1 HTTP server to listen on. If .Em none is specified, the HTTP server is turned off entirely. Note that at least one of the HTTP and HTTPS servers must be turned on. Defaults to .Qq 127.0.0.1:0 . .It Sy https_listen = Em none | Qo Em bind-name Qc ; specifies the address for the .Xr pizauth 1 HTTPS server to listen on. If .Em none is specified, the HTTPS server is turned off entirely. Note that at least one of the HTTP and HTTPS servers must be turned on. Defaults to .Qq 127.0.0.1:0 . .It Sy refresh_at_least = Em time ; specifies the maximum period of time before an access token will be forcibly refreshed. Defaults to 90 minutes if not specified. .It Sy refresh_before_expiry = Em time ; specifies how far in advance an access token should be refreshed before it expires. Defaults to 90 seconds if not specified. .It Sy refresh_retry = Em time ; specifies the gap between retrying refreshing after transitory errors (e.g. due to network problems). Defaults to 40 seconds if not specified. .It Sy startup_cmd = Qo Em shell-cmd Qc ; specifies a shell command to be run via .Ql $SHELL -c after pizauth's server has completed setup and is ready to accept commands. Note that unless .Fl d is set, .Sy startup_cmd will be run after .Xr pizauth 1 has daemonised. The command will thus be run with stdin and stdin closed. .It Sy token_event_cmd = Qo Em shell-cmd Qc ; specifies a shell command to be run via .Ql $SHELL -c when an account's access token changes state. Two special environment variables are set: .Em $PIZAUTH_ACCOUNT is set to the account name; .Em $PIZAUTH_EVENT is set to the event type. The event types are: .Em token_invalidated if a previously valid access token is invalidated; .Em token_new if a new access token is obtained; .Em token_refreshed if an access token is refreshed; .Em token_revoked if the user has requested that any token, or ongoing authentication for, an account should be removed or cancelled. Token events are queued and processed one-by-one in the order they were received: at most one instance of .Sy token_event_cmd will be executed at any point in time; and there is no guarantee that an event reflects the current state of an account's access token, since further events may be stored in the queue. Note that .Sy token_event_cmd is subject to a 10 second timeout. Optional. .It Sy transient_error_if_cmd = Qo Em shell-cmd Qc ; specifies a shell command to be run when pizauth repeatedly encounters errors when trying to refresh a token. One special environment variable is set: .Em $PIZAUTH_ACCOUNT is set to the account name. If .Em shell-cmd returns a zero exit code, the transient errors are ignored. If .Em shell-cmd returns a non-zero exit code, or exceeds a 3 minute timeout, pizauth treats the errors as permanent: the access token is invalidated (forcing the user to later reauthenicate). Defaults to ignoring non-fatal errors if not specified. .El .Pp An .Sq account block supports the following options: .Bl -tag -width Ds .It Sy auth_uri = Qo Em URI Qc ; where .Em URI is a URI specifying the OAuth2 server's authentication URI. Mandatory. .It Sy auth_uri_fields = { Qo Em Key 1 Qc : Qo Em Val 1 Qc , ..., Qo Em Key n Qc : Qo Val n Qc } ; specifies zero or more query fields to be passed to .Sy auth_uri after any fields that .Nm may have added itself. Keys (and their values) are added to .Sy auth_uri in the order they appear in .Sy auth_uri_fields , each separated by .Qq & . The same key may be specified multiple times. Optional. .It Sy client_id = Qo Em ID Qc ; specifies the OAuth2 client ID (i.e. the identifier of the client software). Mandatory. .It Sy client_secret = Qo Em Secret Qc ; specifies the OAuth2 client secret (similar to the .Em client_id ) . Optional. .It Sy login_hint = Qo Em Hint Qc ; is used by the authentication server to help the user understand which account they are authenticating. Typically a username or email address. Optional. .Em Deprecated : use .Ql auth_uri_fields = { Qo login_hint Qc : Qo Hint Qc } instead. .It Sy redirect_uri = Qo Em URI Qc ; where .Em URI is a URI specifying the OAuth2 server's redirection URI. Defaults to .Qq http://localhost/ if not specified. .It Sy refresh_at_least = Em time ; Overrides the global .Sy refresh_at_least option for this account. Follows the same format as the global option. .It Sy refresh_before_expiry = Em time ; Overrides the global .Sy refresh_before_expiry option for this account. Follows the same format as the global option. .It Sy refresh_retry = Em time ; Overrides the global .Sy refresh_retry option for this account. Follows the same format as the global option. .It Sy scopes = [ Qo Em Scope 1 Qc , ..., Qo Em Scope n Qc ] ; specifies zero or more OAuth2 scopes (roughly speaking, .Qq permissions ) that access tokens will give you permission to utilise. Optional. .It Sy token_uri = Qo Em URI Qc ; is a URI specifying the OAuth2 server's token URI. Mandatory. .El .Pp Times can be specified as .Em int [smhd] where the suffixes mean (in order): seconds, minutes, hours, days. For example, .Em 90s means 90 seconds and .Em 5m means 5 minutes. .Sh EXAMPLES An example .Nm file for accessing IMAP and SMTP services in Office365 is as follows: .Bd -literal -offset 4n account "officesmtp" { auth_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"; token_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/token"; client_id = "..."; // Fill in with your Client ID client_secret = "..."; // Fill in with your Client secret scopes = [ "https://outlook.office365.com/IMAP.AccessAsUser.All", "https://outlook.office365.com/SMTP.Send", "offline_access" ]; // You don't have to specify login_hint, but it does make // authentication a little easier. auth_uri_fields = { "login_hint": "email@example.com" }; } .Ed .Pp Note that Office365 requires the non-standard .Qq offline_access scope to be specified in order for .Xr pizauth 1 to be able to operate successfully. .Sh SEE ALSO .Xr pizauth 1 .Pp .Lk https://tratt.net/laurie/src/pizauth/ .Sh AUTHORS .An -nosplit .Xr pizauth 1 was written by .An Laurence Tratt Lk https://tratt.net/laurie/ ================================================ FILE: share/bash/completion.bash ================================================ #!/bin/bash _server() { local cur prev prev=${COMP_WORDS[COMP_CWORD - 1]} cur=${COMP_WORDS[COMP_CWORD]} case "$prev" in -c) _filedir;; *) mapfile -t COMPREPLY < \ <(compgen -W '-c -d -v -vv -vvv -vvvv' -- "$cur");; esac } _accounts(){ local config config="$(pizauth info | awk -F' *: *' '$1 ~ /config file/ { print $2 }')" sed -n '/^account/{s/^account \(.*\) {/\1/;p}' "$config" } _pizauth() { local cur prev sub local cmds=() cmds+=(dump restore reload shutdown status) cmds+=(info server) cmds+=(refresh revoke show) cur=${COMP_WORDS[COMP_CWORD]} prev=${COMP_WORDS[COMP_CWORD - 1]} sub=${COMP_WORDS[1]} if [ "$sub" == server ] && [ "$COMP_CWORD" -gt 1 ]; then _server; return; fi case ${COMP_CWORD} in 1) mapfile -t COMPREPLY < <(compgen -W "${cmds[*]}" -- "$cur");; 2) case $sub in dump|restore|reload|shutdown|status) COMPREPLY=();; info) mapfile -t COMPREPLY < <(compgen -W '-j' -- "$cur") ;; refresh|show) local accounts mapfile -t accounts < <(_accounts) accounts+=(-u) mapfile -t COMPREPLY < \ <(compgen -W "${accounts[*]}" -- "$cur") ;; revoke) local accounts mapfile -t accounts < <(_accounts) mapfile -t COMPREPLY < \ <(compgen -W "${accounts[*]}" -- "$cur") ;; *) COMPREPLY=() ;; esac ;; 3) case $sub in refresh|show) case $prev in -u) local accounts mapfile -t accounts < <(_accounts) mapfile -t COMPREPLY < \ <(compgen -W "${accounts[*]}" -- "$cur") ;; *) COMPREPLY=() esac ;; esac ;; *) COMPREPLY=() ;; esac } complete -F _pizauth pizauth ================================================ FILE: share/fish/pizauth.fish ================================================ #!/usr/bin/fish function __fish_pizauth_accounts --description "Helper function to parse accounts from config" set -l config (pizauth info | awk -F' *: *' '$1 ~ /config file/ { print $2 }') sed -n '/^account/{s/^account \(.*\) {/\1/;p}' $config | string unescape end function __fish_pizauth_is_main_command --description "Returns true if we're not in a subcommand" not __fish_seen_subcommand_from dump restore reload shutdown status info server refresh revoke show end # Don't autocomplete files complete -c pizauth -f # pizauth top-level commands complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Writes current pizauth state to stdout" -a "dump" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Writes output about pizauth to stdout" -a "info" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Request a refresh of the access token for account" -a "refresh" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Reloads the server's configuration" -a "reload" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Reads previously dumped pizauth state from stdin" -a "restore" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Removes token and cancels authorization for account" -a "revoke" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Start the server" -a "server" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Print access token of account to stdout" -a "show" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Shut the server down" -a "shutdown" complete -c pizauth -n "__fish_pizauth_is_main_command" -d "Writes output about current accounts to stdout" -a "status" # pizauth info [-j] complete -c pizauth -n "__fish_seen_subcommand_from info" -s j -d "JSON output" # pizauth refresh/show [-u] account complete -c pizauth -n "__fish_seen_subcommand_from refresh show" -s u -d "Exclude authorization URL" complete -c pizauth -n "__fish_seen_subcommand_from refresh show" -a "(__fish_pizauth_accounts)" # pizauth revoke account complete -c pizauth -n "__fish_seen_subcommand_from revoke" -a "(__fish_pizauth_accounts)" # pizauth server [-c config-file] [-dv] complete -c pizauth -n "__fish_seen_subcommand_from server" -s c -r -F -d "Config file" complete -c pizauth -n "__fish_seen_subcommand_from server" -s d -d "Do not daemonise" complete -c pizauth -n "__fish_seen_subcommand_from server" -o v -d "Verbose" complete -c pizauth -n "__fish_seen_subcommand_from server" -o vv -d "Verboser" complete -c pizauth -n "__fish_seen_subcommand_from server" -o vvv -d "Verboserer" complete -c pizauth -n "__fish_seen_subcommand_from server" -o vvvv -d "Verbosest" ================================================ FILE: share/zsh/_pizauth ================================================ #compdef pizauth _pizauth_accounts() { local config account local -a accounts accounts=("${(@f)$(pizauth status 2>/dev/null | cut -d ':' -f 1)}") (( ${#accounts} )) || return 1 _wanted accounts expl 'account' compadd "$expl[@]" -a accounts } _pizauth() { local curcontext="$curcontext" state line ret=1 local -a commands typeset -A opt_args commands=( 'dump:write internal state to stdout for later restore' 'info:write config information to stdout' 'refresh:request refresh of an access token' 'reload:reload server config' 'restore:read previously dumped state from stdin' 'revoke:remove local access token' 'server:start the server' 'show:write access token to stdout' 'shutdown:shut server down' 'status:write accounts state to stdout' ) _arguments -C \ '1:command:->command' \ '*::argument:->argument' && ret=0 case "$state" in command) _describe -t commands 'pizauth command' commands && ret=0 ;; argument) curcontext="${curcontext%:*:*}:pizauth-${words[1]}:" case $words[1] in dump|reload|restore|shutdown|status) _message 'no more arguments' ;; info) _arguments '-j[write JSON output]' ;; refresh|show) _arguments \ '-u[do not include an authorization URL in errors]' \ '1:account:_pizauth_accounts' ;; revoke) _arguments '1:account:_pizauth_accounts' ;; server) _arguments \ '-c[config file]:config file:_files' \ '-d[do not daemonise]' \ '*-v[verbose: repeat for greater verbosity]' ;; *) _message 'unknown pizauth command' ;; esac ;; esac return ret } _pizauth "$@" ================================================ FILE: src/compat/daemon.rs ================================================ //! Provides daemon(3) on macOS. // We provide our own wrapper for daemon on macOS because nix does not export one for macOS. This // is *probably* why nix does not support daemon(3) on macOS: // // - nix will not compile on macOS, due to errors // - ... nix compiles with #[deny(warnings)], which treats warnings as errors // - libc emits a deprecation warning for daemon(3) on macOS [1] // - ... because daemon(3) has been deprecated in macOS since Mac OS X 10.5 // - ... presumably because Apple wants you to use launchd(8) instead [2]. // - Therefore, this deprecation warning is treated as an error in nix // // [1]: https://github.com/rust-lang/libc/blob/96c85c1b913604fb5b1eb8822e344b7c08bcd6b9/src/unix/bsd/apple/mod.rs#L5064-L5067 // [2]: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html // // This module essentially reimplements nix's daemon wrapper on macOS, but allows deprecation // warnings. // // See: https://github.com/ltratt/pizauth/issues/3 use libc::c_int; #[allow(deprecated)] use libc::daemon as libc_daemon; use nix::errno::Errno; pub fn daemon(nochdir: bool, noclose: bool) -> nix::Result<()> { #[allow(deprecated)] let res = unsafe { libc_daemon(nochdir as c_int, noclose as c_int) }; Errno::result(res).map(drop) } ================================================ FILE: src/compat/mod.rs ================================================ //! Shims to provide compatibility with different systems. // nix does not support daemon(3) on macOS, so we have to provide our own implementation: #[cfg(target_os = "macos")] mod daemon; #[cfg(target_os = "macos")] pub use daemon::daemon; // Use nix's daemon(3) wrapper on other platforms: #[cfg(not(target_os = "macos"))] pub use nix::unistd::daemon; ================================================ FILE: src/config.l ================================================ %% [0-9]+[dhms] "TIME" "(?:\\[\\"]|[^"\\])*" "STRING" = "=" , "," \{ "{" \} "}" \[ "[" \] "]" ; ";" : ":" account "ACCOUNT" auth_error_cmd "AUTH_ERROR_CMD" auth_notify_cmd "AUTH_NOTIFY_CMD" auth_notify_interval "AUTH_NOTIFY_INTERVAL" auth_uri "AUTH_URI" auth_uri_fields "AUTH_URI_FIELDS" client_id "CLIENT_ID" client_secret "CLIENT_SECRET" error_notify_cmd "ERROR_NOTIFY_CMD" http_listen "HTTP_LISTEN" https_listen "HTTPS_LISTEN" login_hint "LOGIN_HINT" none "NONE" refresh_retry "REFRESH_RETRY" redirect_uri "REDIRECT_URI" refresh_before_expiry "REFRESH_BEFORE_EXPIRY" refresh_at_least "REFRESH_AT_LEAST" scopes "SCOPES" startup_cmd "STARTUP_CMD" token_event_cmd "TOKEN_EVENT_CMD" token_uri "TOKEN_URI" transient_error_if_cmd "TRANSIENT_ERROR_IF_CMD" //.*?$ ; [ \t\n\r]+ ; . "UNMATCHED" ================================================ FILE: src/config.rs ================================================ use std::{ collections::HashMap, error::Error, fs::read_to_string, path::Path, sync::Arc, time::Duration, }; use lrlex::{lrlex_mod, DefaultLexerTypes, LRNonStreamingLexer}; use lrpar::{lrpar_mod, NonStreamingLexer, Span}; use serde::{Deserialize, Serialize}; use url::Url; use wincode::{SchemaRead, SchemaWrite}; use crate::config_ast; lrlex_mod!("config.l"); lrpar_mod!("config.y"); type StorageT = u8; /// How many seconds before an access token's expiry do we try refreshing it? const REFRESH_BEFORE_EXPIRY_DEFAULT: Duration = Duration::from_secs(90); /// How many seconds before we forcibly try refreshing an access token, even if it's not yet /// expired? const REFRESH_AT_LEAST_DEFAULT: Duration = Duration::from_secs(90 * 60); /// How many seconds after a refresh failed in a non-permanent way before we retry refreshing? const REFRESH_RETRY_DEFAULT: Duration = Duration::from_secs(40); /// How many seconds do we raise a notification if it only contains authorisations that have been /// shown before? const AUTH_NOTIFY_INTERVAL_DEFAULT: u64 = 15 * 60; /// What is the default bind() address for the HTTP server? const HTTP_LISTEN_DEFAULT: &str = "127.0.0.1:0"; /// What is the default bind() address for the HTTPS server? const HTTPS_LISTEN_DEFAULT: &str = "127.0.0.1:0"; #[derive(Debug)] pub struct Config { pub accounts: HashMap>, pub auth_notify_cmd: Option, pub auth_notify_interval: Duration, pub error_notify_cmd: Option, pub http_listen: Option, pub https_listen: Option, pub transient_error_if_cmd: Option, refresh_at_least: Option, refresh_before_expiry: Option, refresh_retry: Option, pub startup_cmd: Option, pub token_event_cmd: Option, } impl Config { /// Create a `Config` from `path`, returning `Err(String)` (containing a human readable /// message) if it was unable to do so. pub fn from_path(conf_path: &Path) -> Result { let input = match read_to_string(conf_path) { Ok(s) => s, Err(e) => return Err(format!("Can't read {:?}: {}", conf_path, e)), }; Config::from_str(&input) } pub fn from_str(input: &str) -> Result { let lexerdef = config_l::lexerdef(); let lexer = lexerdef.lexer(input); let (astopt, errs) = config_y::parse(&lexer); if !errs.is_empty() { let msgs = errs .iter() .map(|e| e.pp(&lexer, &config_y::token_epp)) .collect::>(); return Err(msgs.join("\n")); } let mut accounts = HashMap::new(); let mut auth_notify_cmd = None; let mut auth_notify_interval = None; let mut error_notify_cmd = None; let mut http_listen = None; let mut https_listen = None; let mut transient_error_if_cmd = None; let mut refresh_at_least = None; let mut refresh_before_expiry = None; let mut refresh_retry = None; let mut startup_cmd = None; let mut token_event_cmd = None; match astopt { Some(Ok(opts)) => { for opt in opts { match opt { config_ast::TopLevel::Account(overall_span, name, fields) => { let act_name = unescape_str(lexer.span_str(name)); accounts.insert( act_name.clone(), Arc::new(Account::from_fields( act_name, &lexer, overall_span, fields, )?), ); } config_ast::TopLevel::AuthErrorCmd(span) => { return Err(error_at_span( &lexer, span, "'auth_error_cmd' has been renamed to 'error_notify_cmd'", )); } config_ast::TopLevel::AuthNotifyCmd(span) => { auth_notify_cmd = Some(check_not_assigned_str( &lexer, "auth_notify_cmd", span, auth_notify_cmd, )?) } config_ast::TopLevel::AuthNotifyInterval(span) => { auth_notify_interval = Some(time_str_to_duration(check_not_assigned_time( &lexer, "auth_notify_interval", span, auth_notify_interval, )?)?) } config_ast::TopLevel::ErrorNotifyCmd(span) => { error_notify_cmd = Some(check_not_assigned_str( &lexer, "error_notify_cmd", span, error_notify_cmd, )?) } config_ast::TopLevel::HttpListen(span) => { http_listen = Some(Some(check_not_assigned_str( &lexer, "http_listen", span, http_listen, )?)) } config_ast::TopLevel::HttpListenNone(span) => { check_not_assigned(&lexer, "http_listen", span, http_listen)?; http_listen = Some(None) } config_ast::TopLevel::HttpsListen(span) => { https_listen = Some(Some(check_not_assigned_str( &lexer, "https_listen", span, https_listen, )?)) } config_ast::TopLevel::HttpsListenNone(span) => { check_not_assigned(&lexer, "https_listen", span, https_listen)?; https_listen = Some(None) } config_ast::TopLevel::TransientErrorIfCmd(span) => { transient_error_if_cmd = Some(check_not_assigned_str( &lexer, "transient_error_if_cmd", span, transient_error_if_cmd, )?) } config_ast::TopLevel::RefreshAtLeast(span) => { refresh_at_least = Some(time_str_to_duration(check_not_assigned_time( &lexer, "refresh_at_least", span, refresh_at_least, )?)?) } config_ast::TopLevel::RefreshBeforeExpiry(span) => { refresh_before_expiry = Some(time_str_to_duration(check_not_assigned_time( &lexer, "refresh_before_expiry", span, refresh_before_expiry, )?)?) } config_ast::TopLevel::RefreshRetry(span) => { refresh_retry = Some(time_str_to_duration(check_not_assigned_time( &lexer, "refresh_retry", span, refresh_retry, )?)?) } config_ast::TopLevel::StartupCmd(span) => { startup_cmd = Some(check_not_assigned_str( &lexer, "startup_cmd", span, startup_cmd, )?) } config_ast::TopLevel::TokenEventCmd(span) => { token_event_cmd = Some(check_not_assigned_str( &lexer, "token_event_cmd", span, token_event_cmd, )?) } } } } _ => unreachable!(), } if let (&Some(None), &Some(None)) = (&http_listen, &https_listen) { return Err("Cannot set both http_listen and https_listen to 'none'".into()); } if accounts.is_empty() { return Err("Must specify at least one account".into()); } for (act_name, act) in &accounts { if act.redirect_uri.starts_with("https") { match https_listen { Some(Some(_)) | None => (), Some(None) => { return Err(format!("Account {act_name} has an 'https' redirect but the HTTPS server is set to 'none'")); } } } else if act.redirect_uri.starts_with("http") { match http_listen { Some(Some(_)) | None => (), Some(None) => { return Err(format!("Account {act_name} has an 'http' redirect but the HTTP server is set to 'none'")); } } } } Ok(Config { accounts, auth_notify_cmd, auth_notify_interval: auth_notify_interval .unwrap_or_else(|| Duration::from_secs(AUTH_NOTIFY_INTERVAL_DEFAULT)), error_notify_cmd, http_listen: http_listen.unwrap_or_else(|| Some(HTTP_LISTEN_DEFAULT.to_owned())), https_listen: https_listen.unwrap_or_else(|| Some(HTTPS_LISTEN_DEFAULT.to_owned())), transient_error_if_cmd, refresh_at_least, refresh_before_expiry, refresh_retry, startup_cmd, token_event_cmd, }) } } fn check_not_assigned( lexer: &LRNonStreamingLexer>, name: &str, span: Span, v: Option, ) -> Result<(), String> { match v { None => Ok(()), Some(_) => Err(error_at_span( lexer, span, &format!("Mustn't specify '{name:}' more than once"), )), } } fn check_not_assigned_str( lexer: &LRNonStreamingLexer>, name: &str, span: Span, v: Option, ) -> Result { match v { None => Ok(unescape_str(lexer.span_str(span))), Some(_) => Err(error_at_span( lexer, span, &format!("Mustn't specify '{name:}' more than once"), )), } } fn check_not_assigned_time<'a, T>( lexer: &'a LRNonStreamingLexer>, name: &str, span: Span, v: Option, ) -> Result<&'a str, String> { match v { None => Ok(lexer.span_str(span)), Some(_) => Err(error_at_span( lexer, span, &format!("Mustn't specify '{name:}' more than once"), )), } } fn check_not_assigned_uri( lexer: &LRNonStreamingLexer>, name: &str, span: Span, v: Option, ) -> Result { match v { None => { let s = unescape_str(lexer.span_str(span)); match Url::parse(&s) { Ok(x) => { if x.fragment().is_some() { Err(error_at_span( lexer, span, "URI fragments ('#...') are not allowed", )) } else if x.scheme() == "http" || x.scheme() == "https" { Ok(s) } else { Err(error_at_span(lexer, span, "not a valid HTTP or HTTPS URI")) } } Err(e) => Err(error_at_span(lexer, span, &format!("Invalid URI: {e:}"))), } } Some(_) => Err(error_at_span( lexer, span, &format!("Mustn't specify '{name:}' more than once"), )), } } fn check_assigned( lexer: &LRNonStreamingLexer>, name: &str, span: Span, v: Option, ) -> Result { match v { Some(x) => Ok(x), None => Err(error_at_span( lexer, span, &format!("{name:} not specified"), )), } } /// If you add to the, or alter the semantics of any existing, fields in this struct, you *must* /// check whether any of the following also need to be chnaged: /// * `Account::secure_eq` /// * `Account::dump` /// * `Account::secure_restoreable` /// * `AccountDump` /// /// These functions are vital to the security guarantees pizauth makes when reloading/restoring /// configurations. #[derive(Clone, Debug)] pub struct Account { pub name: String, pub auth_uri: String, pub auth_uri_fields: Vec<(String, String)>, pub client_id: String, pub client_secret: Option, redirect_uri: String, refresh_at_least: Option, refresh_before_expiry: Option, refresh_retry: Option, pub scopes: Vec, pub token_uri: String, } impl Account { fn from_fields( name: String, lexer: &LRNonStreamingLexer>, overall_span: Span, fields: Vec, ) -> Result { let mut auth_uri = None; let mut auth_uri_fields = None; let mut client_id = None; let mut client_secret = None; let mut login_hint = None; let mut redirect_uri = None; let mut refresh_at_least = None; let mut refresh_before_expiry = None; let mut refresh_retry = None; let mut scopes = None; let mut token_uri = None; for f in fields { match f { config_ast::AccountField::AuthUri(span) => { auth_uri = Some(check_not_assigned_uri(lexer, "auth_uri", span, auth_uri)?) } config_ast::AccountField::AuthUriFields(span, spans) => { if auth_uri_fields.is_some() { debug_assert!(!spans.is_empty()); return Err(error_at_span( lexer, span, "Mustn't specify 'auth_uri_fields' more than once", )); } auth_uri_fields = Some( spans .iter() .map(|(key_sp, val_sp)| { ( unescape_str(lexer.span_str(*key_sp)), unescape_str(lexer.span_str(*val_sp)), ) }) .collect::>(), ); } config_ast::AccountField::ClientId(span) => { client_id = Some(check_not_assigned_str(lexer, "client_id", span, client_id)?) } config_ast::AccountField::ClientSecret(span) => { client_secret = Some(check_not_assigned_str( lexer, "client_secret", span, client_secret, )?) } config_ast::AccountField::LoginHint(span) => { login_hint = Some(check_not_assigned_str( lexer, "login_hint", span, login_hint, )?) } config_ast::AccountField::RedirectUri(span) => { let uri = check_not_assigned_uri(lexer, "redirect_uri", span, redirect_uri)?; redirect_uri = Some(uri) } config_ast::AccountField::RefreshAtLeast(span) => { refresh_at_least = Some(time_str_to_duration(check_not_assigned_time( lexer, "refresh_at_least", span, refresh_at_least, )?)?) } config_ast::AccountField::RefreshBeforeExpiry(span) => { refresh_before_expiry = Some(time_str_to_duration(check_not_assigned_time( lexer, "refresh_before_expiry", span, refresh_before_expiry, )?)?) } config_ast::AccountField::RefreshRetry(span) => { refresh_retry = Some(time_str_to_duration(check_not_assigned_time( lexer, "refresh_retry", span, refresh_retry, )?)?) } config_ast::AccountField::Scopes(span, spans) => { if scopes.is_some() { debug_assert!(!spans.is_empty()); return Err(error_at_span( lexer, span, "Mustn't specify 'scopes' more than once", )); } scopes = Some( spans .iter() .map(|sp| unescape_str(lexer.span_str(*sp))) .collect::>(), ); } config_ast::AccountField::TokenUri(span) => { token_uri = Some(check_not_assigned_uri(lexer, "token_uri", span, token_uri)?) } } } let auth_uri = check_assigned(lexer, "auth_uri", overall_span, auth_uri)?; let client_id = check_assigned(lexer, "client_id", overall_span, client_id)?; let token_uri = check_assigned(lexer, "token_uri", overall_span, token_uri)?; // We allow the deprecated `login_hint` field through but don't want to allow it to clash // with a field of the same name in `auth_uri_fields`. if let (Some(_), Some(auth_uri_fields)) = (&login_hint, &auth_uri_fields) { if auth_uri_fields.iter().any(|(k, _)| k == "login_hint") { 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.")); } } Ok(Account { name, auth_uri, auth_uri_fields: auth_uri_fields.unwrap_or_default(), client_id, client_secret, redirect_uri: redirect_uri.unwrap_or_else(|| "http://localhost/".to_owned()), refresh_at_least, refresh_before_expiry, refresh_retry, scopes: scopes.unwrap_or_default(), token_uri, }) } /// Are the security relevant parts of this `Account` the same as `other`? /// /// Note that this is a weaker condition than "is `self` equal to `other`" because there are /// some parts of an `Account`'s configuration that are irrelevant from a security perspective. /// If you add new fields to, or change the semantics of existing fields in, `Account`, you /// must reconsider this function. pub fn secure_eq(&self, other: &Self) -> bool { // Our definition of "are the security relevant parts of this `Account` the same as // `other`" is roughly: if anything here changes could we end up giving out an access token // that the user might send to the wrong server? Note that it is better to be safe than // sorry: if in doubt, it is better to have more, rather than fewer, fields compared here. self.name == other.name && self.auth_uri == other.auth_uri && self.auth_uri_fields == other.auth_uri_fields && self.client_id == other.client_id && self.client_secret == other.client_secret && self.redirect_uri == other.redirect_uri && self.scopes == other.scopes && self.token_uri == other.token_uri } pub fn dump(&self) -> AccountDump { AccountDump { auth_uri: self.auth_uri.clone(), auth_uri_fields: self.auth_uri_fields.clone(), client_id: self.client_id.clone(), client_secret: self.client_secret.clone(), redirect_uri: self.redirect_uri.clone(), scopes: self.scopes.clone(), token_uri: self.token_uri.clone(), } } /// Can this account's tokenstate safely be restored from an [AccountDump] `act_dump`? Roughly /// speaking, if `act_dump` was converted into an `Account`, would that new `Account` compare /// equal with `secure_eq` to `self`? If `true`, then it is safe to restore the (`self`) /// `Account`'s tokenstate from a dump. pub fn secure_restorable(&self, act_dump: &AccountDump) -> bool { self.auth_uri == act_dump.auth_uri && self.auth_uri_fields == act_dump.auth_uri_fields && self.client_id == act_dump.client_id && self.client_secret == act_dump.client_secret && self.redirect_uri == act_dump.redirect_uri && self.scopes == act_dump.scopes && self.token_uri == act_dump.token_uri } pub fn redirect_uri( &self, http_port: Option, https_port: Option, ) -> Result> { assert!(http_port.is_some() || https_port.is_some()); let mut url = Url::parse(&self.redirect_uri)?; if https_port.is_some() && self.redirect_uri.to_lowercase().starts_with("https") { url.set_port(https_port) .map_err(|_| "Cannot set https port")?; } else { url.set_port(http_port) .map_err(|_| "Cannot set http port")?; } Ok(url) } pub fn refresh_at_least(&self, config: &Config) -> Duration { self.refresh_at_least .or(config.refresh_at_least) .unwrap_or(REFRESH_AT_LEAST_DEFAULT) } pub fn refresh_before_expiry(&self, config: &Config) -> Duration { self.refresh_before_expiry .or(config.refresh_before_expiry) .unwrap_or(REFRESH_BEFORE_EXPIRY_DEFAULT) } pub fn refresh_retry(&self, config: &Config) -> Duration { self.refresh_retry .or(config.refresh_retry) .unwrap_or(REFRESH_RETRY_DEFAULT) } } #[derive(Deserialize, Serialize, SchemaRead, SchemaWrite)] pub struct AccountDump { auth_uri: String, auth_uri_fields: Vec<(String, String)>, client_id: String, client_secret: Option, redirect_uri: String, scopes: Vec, token_uri: String, } /// Given a time duration in the format `[0-9]+[dhms]` return a [Duration]. /// /// # Panics /// /// If `t` is not in the format `[0-9]+[dhms]`. fn time_str_to_duration(t: &str) -> Result { fn inner(t: &str) -> Result> { let last_char_idx = t .chars() .filter(|c| c.is_numeric()) .map(|c| c.len_utf8()) .sum(); debug_assert!(last_char_idx < t.len()); let num = t[..last_char_idx].parse::()?; let secs = match t.chars().last().unwrap() { 'd' => num.checked_mul(86400).ok_or("Number too big")?, 'h' => num.checked_mul(3600).ok_or("Number too big")?, 'm' => num.checked_mul(60).ok_or("Number too big")?, 's' => num, _ => unreachable!(), }; Ok(Duration::from_secs(secs)) } inner(t).map_err(|e| format!("Invalid time: {e}")) } /// Take a quoted string from the config file and unescape it (i.e. strip the start and end quote /// (") characters and process any escape characters in the string.) fn unescape_str(us: &str) -> String { // The regex in config.l should have guaranteed that strings start and finish with a // quote character. debug_assert!(us.starts_with('"') && us.ends_with('"')); let mut s = String::new(); // We iterate over all characters except the opening and closing quote characters. let mut i = '"'.len_utf8(); while i < us.len() - '"'.len_utf8() { let c = us[i..].chars().next().unwrap(); if c == '\\' { // The regex in config.l should have guaranteed that there are no unescaped quote (") // characters, but we check here just to be sure. debug_assert!(i < us.len() - '"'.len_utf8()); i += 1; let c2 = us[i..].chars().next().unwrap(); debug_assert!(c2 == '"' || c2 == '\\'); s.push(c2); i += c2.len_utf8(); } else { s.push(c); i += c.len_utf8(); } } s } /// Return an error message pinpointing `span` as the culprit. fn error_at_span( lexer: &LRNonStreamingLexer>, span: Span, msg: &str, ) -> String { let ((line_off, col), _) = lexer.line_col(span); let code = lexer .span_lines_str(span) .split('\n') .next() .unwrap() .trim(); format!( "Line {}, column {}:\n {}\n{}", line_off, col, code.trim(), msg ) } #[cfg(test)] mod test { use super::*; use lrpar::Lexer; #[test] fn test_unescape_string() { assert_eq!(unescape_str("\"\""), ""); assert_eq!(unescape_str("\"a\""), "a"); assert_eq!(unescape_str("\"a\\\"\""), "a\""); assert_eq!(unescape_str("\"a\\\"b\""), "a\"b"); assert_eq!(unescape_str("\"\\\\\""), "\\"); } #[test] fn test_time_str_to_duration() { assert_eq!(time_str_to_duration("0s").unwrap(), Duration::from_secs(0)); assert_eq!(time_str_to_duration("1s").unwrap(), Duration::from_secs(1)); assert_eq!(time_str_to_duration("1m").unwrap(), Duration::from_secs(60)); assert_eq!( time_str_to_duration("2m").unwrap(), Duration::from_secs(120) ); assert_eq!( time_str_to_duration("1h").unwrap(), Duration::from_secs(3600) ); assert_eq!( time_str_to_duration("1d").unwrap(), Duration::from_secs(86400) ); assert!(time_str_to_duration("9223372036854775808m").is_err()); } #[test] fn string_escapes() { let lexerdef = config_l::lexerdef(); let lexemes = lexerdef.lexer("\"\\\\\"").iter().collect::>(); assert_eq!(lexemes.len(), 1); let lexemes = lexerdef.lexer("\"\\\"\\\"\"").iter().collect::>(); assert_eq!(lexemes.len(), 1); let lexemes = lexerdef.lexer("\"\\n\"").iter().collect::>(); assert_eq!(lexemes.len(), 4); } #[test] fn valid_config() { let c = Config::from_str( r#" auth_notify_cmd = "g"; auth_notify_interval = 88m; error_notify_cmd = "j"; http_listen = "127.0.0.1:56789"; transient_error_if_cmd = "k"; token_event_cmd = "q"; account "x" { // Mandatory fields auth_uri = "http://a.com"; auth_uri_fields = {"l": "m", "n": "o", "l": "p"}; client_id = "b"; scopes = ["c", "d"]; token_uri = "http://f.com"; // Optional fields client_secret = "h"; login_hint = "i"; redirect_uri = "http://e.com"; refresh_at_least = 43m; refresh_before_expiry = 42s; refresh_retry = 33s; } "#, ) .unwrap(); assert_eq!(c.error_notify_cmd, Some("j".to_owned())); assert_eq!(c.auth_notify_cmd, Some("g".to_owned())); assert_eq!(c.auth_notify_interval, Duration::from_secs(88 * 60)); assert_eq!(c.http_listen, Some("127.0.0.1:56789".to_owned())); assert_eq!(c.transient_error_if_cmd, Some("k".to_owned())); assert_eq!(c.token_event_cmd, Some("q".to_owned())); let act = &c.accounts["x"]; assert_eq!(act.auth_uri, "http://a.com"); assert_eq!( &act.auth_uri_fields, &[ ("l".to_owned(), "m".to_owned()), ("n".to_owned(), "o".to_owned()), ("l".to_owned(), "p".to_owned()) ] ); assert_eq!(act.client_id, "b"); assert_eq!(act.client_secret, Some("h".to_owned())); assert_eq!(act.redirect_uri, "http://e.com"); assert_eq!(act.token_uri, "http://f.com"); assert_eq!(&act.scopes, &["c".to_owned(), "d".to_owned()]); assert_eq!(act.refresh_at_least, Some(Duration::from_secs(43 * 60))); assert_eq!(act.refresh_before_expiry, Some(Duration::from_secs(42))); assert_eq!(act.refresh_retry(&c), Duration::from_secs(33)); } #[test] fn at_least_one_account() { assert_eq!( Config::from_str("").unwrap_err().as_str(), "Must specify at least one account" ); } #[test] fn invalid_time() { match Config::from_str("auth_notify_interval = 18446744073709551616s;") { Err(s) if s.contains("Invalid time: number too large") => (), _ => panic!(), } } #[test] fn dup_fields() { match Config::from_str(r#"auth_notify_cmd = "a"; auth_notify_cmd = "a";"#) { Err(s) if s.contains("Mustn't specify 'auth_notify_cmd' more than once") => (), _ => panic!(), } match Config::from_str("auth_notify_interval = 1s; auth_notify_interval = 2s;") { Err(s) if s.contains("Mustn't specify 'auth_notify_interval' more than once") => (), _ => panic!(), } match Config::from_str(r#"error_notify_cmd = "a"; error_notify_cmd = "a";"#) { Err(s) if s.contains("Mustn't specify 'error_notify_cmd' more than once") => (), _ => panic!(), } match Config::from_str(r#"token_event_cmd = "a"; token_event_cmd = "a";"#) { Err(s) if s.contains("Mustn't specify 'token_event_cmd' more than once") => (), _ => panic!(), } match Config::from_str(r#"transient_error_if_cmd = "a"; transient_error_if_cmd = "b";"#) { Err(s) if s.contains("Mustn't specify 'transient_error_if_cmd' more than once") => (), _ => panic!(), } match Config::from_str(r#"http_listen = "a"; http_listen = "b";"#) { Err(s) if s.contains("Mustn't specify 'http_listen' more than once") => (), _ => panic!(), } match Config::from_str(r#"http_listen = none; http_listen = "a";"#) { Err(s) if s.contains("Mustn't specify 'http_listen' more than once") => (), _ => panic!(), } match Config::from_str(r#"https_listen = "a"; https_listen = "b";"#) { Err(s) if s.contains("Mustn't specify 'https_listen' more than once") => (), _ => panic!(), } match Config::from_str(r#"https_listen = none; https_listen = "a";"#) { Err(s) if s.contains("Mustn't specify 'https_listen' more than once") => (), _ => panic!(), } fn account_dup(field: &str, values: &[&str]) { let c = format!( "account \"x\" {{ {} }}", values .iter() .map(|v| format!("{field:} = {v:};")) .collect::>() .join(" ") ); match Config::from_str(&c) { Err(s) if s.contains(&format!("Mustn't specify '{field:}' more than once")) => (), Err(e) => panic!("{e:}"), _ => panic!(), } } account_dup("auth_uri", &[r#""http://a.com/""#, r#""http://b.com/""#]); account_dup("auth_uri_fields", &[r#"{"a": "b"}"#, r#"{"c": "d"}"#]); account_dup("client_id", &[r#""a""#, r#""b""#]); account_dup("client_secret", &[r#""a""#, r#""b""#]); account_dup("login_hint", &[r#""a""#, r#""b""#]); account_dup( "redirect_uri", &[r#""http://a.com/""#, r#""http://b.com/""#], ); account_dup("refresh_before_expiry", &["1m", "2m"]); account_dup("refresh_at_least", &["1m", "2m"]); account_dup("scopes", &[r#"["a"]"#, r#"["b"]"#]); account_dup("token_uri", &[r#""http://a.com/""#, r#""http://b.com/""#]); } #[test] fn one_of_http_or_https() { match Config::from_str( r#" http_listen = none; https_listen = none; account "x" { auth_uri = "http://a.com"; client_id = "b"; token_uri = "http://f.com"; } "#, ) { Err(e) if e.contains("Cannot set both http_listen and https_listen to 'none'") => (), Err(e) => panic!("{e:?}"), _ => panic!(), } } #[test] fn http_or_https_redirect_uris_only() { match Config::from_str( r#" account "x" { auth_uri = "http://a.com"; client_id = "b"; redirect_uri = "httpx://"; token_uri = "http://f.com"; } "#, ) { Err(e) if e.contains("not a valid HTTP or HTTPS URI") => (), Err(e) => panic!("{e:?}"), _ => panic!(), } match Config::from_str( r#" account "x" { auth_uri = "http://a.com"; client_id = "b"; redirect_uri = "ftp://blah/"; token_uri = "http://f.com"; } "#, ) { Err(e) if e.contains("not a valid HTTP or HTTPS URI") => (), Err(e) => panic!("{e:?}"), _ => panic!(), } } #[test] fn correct_listen_for_account() { match Config::from_str( r#" http_listen = none; account "x" { auth_uri = "http://a.com"; client_id = "b"; token_uri = "http://f.com"; } "#, ) { Err(e) if e.contains( "Account x has an 'http' redirect but the HTTP server is set to 'none'", ) => { () } Err(e) => panic!("{e:?}"), _ => panic!(), } match Config::from_str( r#" https_listen = none; account "x" { auth_uri = "http://a.com"; client_id = "b"; redirect_uri = "https://c.com"; token_uri = "http://f.com"; } "#, ) { Err(e) if e.contains( "Account x has an 'https' redirect but the HTTPS server is set to 'none'", ) => { () } Err(e) => panic!("{e:?}"), _ => panic!(), } } #[test] fn invalid_uris() { fn invalid_uri(field: &str) { let c = format!(r#"account "x" {{ {field} = "blah"; }}"#); match Config::from_str(&c) { Err(e) if e.contains("Invalid URI") => (), Err(e) => panic!("{e:}"), _ => panic!(), } } invalid_uri("auth_uri"); invalid_uri("redirect_uri"); invalid_uri("token_uri"); } #[test] fn valid_https_config() { let c = Config::from_str( r#" https_listen = "127.0.0.1:56789"; account "x" { // Mandatory fields auth_uri = "http://a.com"; auth_uri_fields = {"l": "m", "n": "o", "l": "p"}; client_id = "b"; scopes = ["c", "d"]; token_uri = "http://f.com"; // Optional fields redirect_uri = "https://e.com"; } "#, ) .unwrap(); assert_eq!(c.https_listen, Some("127.0.0.1:56789".to_owned())); let act = &c.accounts["x"]; assert_eq!(act.redirect_uri, "https://e.com"); let uri = act.redirect_uri(Some(0), Some(56789)).unwrap(); assert_eq!(uri.scheme(), "https"); assert_eq!(uri.port(), Some(56789)); assert_eq!(uri.host_str(), Some("e.com")); } #[test] fn mandatory_account_fields() { let fields = &[ ("auth_uri", r#""http://a.com/""#), ("client_id", r#""a""#), ("token_uri", r#""http://b.com/""#), ]; fn combine(fields: &[(&str, &str)]) -> String { fields .iter() .map(|(k, v)| format!("{k:} = {v:};")) .collect::>() .join("\n") } assert!(Config::from_str(&format!(r#"account "a" {{ {} }}"#, combine(fields))).is_ok()); for i in 0..fields.len() { let mut f = fields.to_vec(); f.remove(i); match Config::from_str(&format!(r#"account "a" {{ {} }}"#, combine(&f))) { Err(e) if e.contains("not specified") => (), Err(e) => panic!("{e:}"), e => panic!("{e:?}"), } } } #[test] fn local_overrides() { // Defaults only let c = Config::from_str( r#" account "x" { auth_uri = "http://a.com"; client_id = "b"; scopes = ["c"]; token_uri = "http://d.com"; } "#, ) .unwrap(); assert_eq!(c.transient_error_if_cmd, None); assert_eq!(c.refresh_at_least, None); assert_eq!(c.refresh_before_expiry, None); assert_eq!(c.refresh_retry, None); let act = &c.accounts["x"]; assert_eq!(act.refresh_at_least(&c), REFRESH_AT_LEAST_DEFAULT); assert_eq!(act.refresh_before_expiry(&c), REFRESH_BEFORE_EXPIRY_DEFAULT); assert_eq!(act.refresh_retry(&c), REFRESH_RETRY_DEFAULT); // Global only let c = Config::from_str( r#" transient_error_if_cmd = "e"; refresh_at_least = 1s; refresh_before_expiry = 2s; refresh_retry = 3s; account "x" { auth_uri = "http://a.com"; client_id = "b"; scopes = ["c"]; token_uri = "http://d.com"; } "#, ) .unwrap(); assert_eq!(c.transient_error_if_cmd, Some("e".to_owned())); assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1))); assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2))); assert_eq!(c.refresh_retry, Some(Duration::from_secs(3))); let act = &c.accounts["x"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(3)); // Local only let c = Config::from_str( r#" account "x" { auth_uri = "http://a.com"; client_id = "b"; scopes = ["c"]; token_uri = "http://d.com"; refresh_at_least = 1s; refresh_before_expiry = 2s; refresh_retry = 3s; } "#, ) .unwrap(); assert_eq!(c.transient_error_if_cmd, None); assert_eq!(c.refresh_at_least, None); assert_eq!(c.refresh_before_expiry, None); assert_eq!(c.refresh_retry, None); let act = &c.accounts["x"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(3)); // Local overrides global let c = Config::from_str( r#" transient_error_if_cmd = "e"; refresh_at_least = 1s; refresh_before_expiry = 2s; refresh_retry = 3s; account "x" { auth_uri = "http://a.com"; client_id = "b"; scopes = ["c"]; token_uri = "http://d.com"; refresh_at_least = 4s; refresh_before_expiry = 5s; refresh_retry = 6s; } "#, ) .unwrap(); assert_eq!(c.transient_error_if_cmd, Some("e".to_owned())); assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1))); assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2))); assert_eq!(c.refresh_retry, Some(Duration::from_secs(3))); let act = &c.accounts["x"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(4)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(5)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(6)); // Local overrides global let c = Config::from_str( r#" transient_error_if_cmd = "e"; refresh_at_least = 1s; refresh_before_expiry = 2s; refresh_retry = 3s; account "x" { auth_uri = "http://a.com"; client_id = "b"; scopes = ["c"]; token_uri = "http://d.com"; refresh_at_least = 4s; refresh_before_expiry = 5s; refresh_retry = 6s; } account "y" { auth_uri = "http://g.com"; client_id = "h"; scopes = ["i"]; token_uri = "http://j.com"; refresh_at_least = 7s; refresh_before_expiry = 8s; refresh_retry = 9s; } account "z" { auth_uri = "http://g.com"; client_id = "h"; scopes = ["i"]; token_uri = "http://j.com"; } "#, ) .unwrap(); assert_eq!(c.transient_error_if_cmd, Some("e".to_owned())); assert_eq!(c.refresh_at_least, Some(Duration::from_secs(1))); assert_eq!(c.refresh_before_expiry, Some(Duration::from_secs(2))); assert_eq!(c.refresh_retry, Some(Duration::from_secs(3))); let act = &c.accounts["x"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(4)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(5)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(6)); let act = &c.accounts["y"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(7)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(8)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(9)); let act = &c.accounts["z"]; assert_eq!(act.refresh_at_least(&c), Duration::from_secs(1)); assert_eq!(act.refresh_before_expiry(&c), Duration::from_secs(2)); assert_eq!(act.refresh_retry(&c), Duration::from_secs(3)); } #[test] fn login_hint_mutually_exclusive_query_field() { let c = r#"account "x" { auth_uri = "http://a.com/"; auth_uri_fields = { "login_hint": "e" }; client_id = "b"; token_uri = "https://c.com/"; login_hint = "d"; }"#; match Config::from_str(c) { 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.") => (), Err(e) => panic!("{e:}"), _ => panic!(), } } #[test] fn endpoints_no_fragment() { let c = r#"account "x" { auth_uri = "http://a.com/#a"; auth_uri_fields = { "login_hint": "e" }; client_id = "b"; token_uri = "https://c.com/"; login_hint = "d"; }"#; match Config::from_str(c) { Err(e) if e.contains("URI fragments ('#...') are not allowed") => (), Err(e) => panic!("{e:}"), _ => panic!(), } let c = r#"account "x" { auth_uri = "http://a.com/"; auth_uri_fields = { "login_hint": "e" }; client_id = "b"; token_uri = "https://c.com/#c"; login_hint = "d"; }"#; match Config::from_str(c) { Err(e) if e.contains("URI fragments ('#...') are not allowed") => (), Err(e) => panic!("{e:}"), _ => panic!(), } } } ================================================ FILE: src/config.y ================================================ %start TopLevels %avoid_insert "STRING" %epp TIME "