Showing preview only (252K chars total). Download the full file or copy to clipboard to get everything.
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 <website>
<port>` 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 <LICENSE-APACHE>
<http://www.apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT>
<http://opensource.org/licenses/MIT>, 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 <laurie@tratt.net>"]
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=<your Client ID>&redirect_uri=http%3A%2F%2Flocalhost%3A14204%2F&response_type=code&state=%25E6%25A0%25EF%2503h6%25BCK&client_secret=<your Client Secret>&login_hint=email@example.com
```
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] <account>
pizauth reload
pizauth restore
pizauth server [-c <config-path>] [-d]
pizauth show [-u] <account>
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 `<this>`) 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 <account-name>
auth xoauth2
host <smtp-server>
protocol smtp
from <email-address>
user <username>
passwordeval pizauth show <pizauth-account-name>
```
### mbsync
Ensure you have the xoauth2 plugin for cyrus-sasl installed, and then use
something like this for the IMAP account in `~/.mbsyncrc`:
```
IMAPAccount <account-name>
Host <imap-server>
User <username>
PassCmd "pizauth show <pizauth-account-name>"
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 "<your-account-name>" {
auth_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize";
auth_uri_fields = { "login_hint": "<your-email-address>" };
token_uri = "https://login.microsoftonline.com/common/oauth2/v2.0/token";
client_id = "<your-client-id>";
client_secret = "<your-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 "<your-account-name>" {
auth_uri = "https://accounts.google.com/o/oauth2/auth";
auth_uri_fields = {"login_hint": "<your-email-address>"};
token_uri = "https://oauth2.googleapis.com/token";
client_id = "<your-client-id>";
client_secret = "<your-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 "<your-account-name>" {
auth_uri = "https://api.mcs3.miele.com/thirdparty/login/";
token_uri = "https://api.mcs3.miele.com/thirdparty/token/";
client_id = "<your-client-id>";
client_secret = "<your-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:<port-number>";
account "..." { ... }
```
Then on your local machine (using the same `<port-number>` as above run `ssh`:
```
ssh -L 127.0.0.1:<port-number>:127.0.0.1:<port-number> <remote>
```
Then on the remote machine start `pizauth server` and then `pizauth show
<account-name>`. 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<dyn std::error::Error>> {
rerun_except(&[
"CHANGES.md",
"LICENSE-APACHE",
"LICENSE-MIT",
"pizauth.1",
"pizauth.conf.5",
"pizauth.conf.example",
"README.md",
])?;
CTLexerBuilder::<DefaultLexerTypes<u8>>::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<String, Arc<Account>>,
pub auth_notify_cmd: Option<String>,
pub auth_notify_interval: Duration,
pub error_notify_cmd: Option<String>,
pub http_listen: Option<String>,
pub https_listen: Option<String>,
pub transient_error_if_cmd: Option<String>,
refresh_at_least: Option<Duration>,
refresh_before_expiry: Option<Duration>,
refresh_retry: Option<Duration>,
pub startup_cmd: Option<String>,
pub token_event_cmd: Option<String>,
}
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<Self, String> {
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<Self, String> {
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::<Vec<_>>();
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<T>(
lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,
name: &str,
span: Span,
v: Option<T>,
) -> 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<T>(
lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,
name: &str,
span: Span,
v: Option<T>,
) -> Result<String, String> {
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<DefaultLexerTypes<StorageT>>,
name: &str,
span: Span,
v: Option<T>,
) -> 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<T>(
lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,
name: &str,
span: Span,
v: Option<T>,
) -> Result<String, String> {
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<T>(
lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,
name: &str,
span: Span,
v: Option<T>,
) -> Result<T, String> {
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<String>,
redirect_uri: String,
refresh_at_least: Option<Duration>,
refresh_before_expiry: Option<Duration>,
refresh_retry: Option<Duration>,
pub scopes: Vec<String>,
pub token_uri: String,
}
impl Account {
fn from_fields(
name: String,
lexer: &LRNonStreamingLexer<DefaultLexerTypes<StorageT>>,
overall_span: Span,
fields: Vec<config_ast::AccountField>,
) -> Result<Self, String> {
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::<Vec<(String, String)>>(),
);
}
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::<Vec<String>>(),
);
}
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<u16>,
https_port: Option<u16>,
) -> Result<Url, Box<dyn Error>> {
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<String>,
redirect_uri: String,
scopes: Vec<String>,
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<Duration, String> {
fn inner(t: &str) -> Result<Duration, Box<dyn Error>> {
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::<u64>()?;
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<DefaultLexerTypes<StorageT>>,
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::<Vec<_>>();
assert_eq!(lexemes.len(), 1);
let lexemes = lexerdef.lexer("\"\\\"\\\"\"").iter().collect::<Vec<_>>();
assert_eq!(lexemes.len(), 1);
let lexemes = lexerdef.lexer("\"\\n\"").iter().collect::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>()
.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 "<time>[dhms]"
%expect-unused Unmatched "UNMATCHED"
%%
TopLevels -> Result<Vec<TopLevel>, ()>:
TopLevels TopLevel { flattenr($1, $2) }
| { Ok(vec![]) }
;
TopLevel -> Result<TopLevel, ()>:
"ACCOUNT" "STRING" "{" AccountFields "}" { Ok(TopLevel::Account($span, map_err($2)?, $4?)) }
| "AUTH_ERROR_CMD" "=" "STRING" ";" { Ok(TopLevel::AuthErrorCmd($span)) }
| "AUTH_NOTIFY_CMD" "=" "STRING" ";" { Ok(TopLevel::AuthNotifyCmd(map_err($3)?)) }
| "AUTH_NOTIFY_INTERVAL" "=" "TIME" ";" { Ok(TopLevel::AuthNotifyInterval(map_err($3)?)) }
| "ERROR_NOTIFY_CMD" "=" "STRING" ";" { Ok(TopLevel::ErrorNotifyCmd(map_err($3)?)) }
| "HTTP_LISTEN" "=" "NONE" ";" { Ok(TopLevel::HttpListenNone(map_err($3)?)) }
| "HTTP_LISTEN" "=" "STRING" ";" { Ok(TopLevel::HttpListen(map_err($3)?)) }
| "HTTPS_LISTEN" "=" "NONE" ";" { Ok(TopLevel::HttpsListenNone(map_err($3)?)) }
| "HTTPS_LISTEN" "=" "STRING" ";" { Ok(TopLevel::HttpsListen(map_err($3)?)) }
| "TRANSIENT_ERROR_IF_CMD" "=" "STRING" ";" { Ok(TopLevel::TransientErrorIfCmd(map_err($3)?)) }
| "REFRESH_AT_LEAST" "=" "TIME" ";" { Ok(TopLevel::RefreshAtLeast(map_err($3)?)) }
| "REFRESH_BEFORE_EXPIRY" "=" "TIME" ";" { Ok(TopLevel::RefreshBeforeExpiry(map_err($3)?)) }
| "REFRESH_RETRY" "=" "TIME" ";" { Ok(TopLevel::RefreshRetry(map_err($3)?)) }
| "STARTUP_CMD" "=" "STRING" ";" { Ok(TopLevel::StartupCmd(map_err($3)?)) }
| "TOKEN_EVENT_CMD" "=" "STRING" ";" { Ok(TopLevel::TokenEventCmd(map_err($3)?)) }
;
AccountFields -> Result<Vec<AccountField>, ()>:
AccountFields AccountField { flattenr($1, $2) }
| { Ok(vec![]) }
;
AccountField -> Result<AccountField, ()>:
"AUTH_URI" "=" "STRING" ";" { Ok(AccountField::AuthUri(map_err($3)?)) }
| "AUTH_URI_FIELDS" "=" "{" AuthUriFields "}" ";" { Ok(AccountField::AuthUriFields($1.unwrap_or_else(|x| x).span(), $4?)) }
| "CLIENT_ID" "=" "STRING" ";" { Ok(AccountField::ClientId(map_err($3)?)) }
| "CLIENT_SECRET" "=" "STRING" ";" { Ok(AccountField::ClientSecret(map_err($3)?)) }
| "LOGIN_HINT" "=" "STRING" ";" { Ok(AccountField::LoginHint(map_err($3)?)) }
| "REDIRECT_URI" "=" "STRING" ";" { Ok(AccountField::RedirectUri(map_err($3)?)) }
| "REFRESH_AT_LEAST" "=" "TIME" ";" { Ok(AccountField::RefreshAtLeast(map_err($3)?)) }
| "REFRESH_BEFORE_EXPIRY" "=" "TIME" ";" { Ok(AccountField::RefreshBeforeExpiry(map_err($3)?)) }
| "REFRESH_RETRY" "=" "TIME" ";" { Ok(AccountField::RefreshRetry(map_err($3)?)) }
| "SCOPES" "=" "[" Scopes "]" ";" { Ok(AccountField::Scopes($1.unwrap_or_else(|x| x).span(), $4?)) }
| "TOKEN_URI" "=" "STRING" ";" { Ok(AccountField::TokenUri(map_err($3)?)) }
;
AuthUriFields -> Result<Vec<(Span, Span)>, ()>:
AuthUriFields "," "STRING" ":" "STRING" {
let mut spans = $1?;
spans.push((map_err($3)?, map_err($5)?));
Ok(spans)
}
| "STRING" ":" "STRING" { Ok(vec![(map_err($1)?, map_err($3)?)]) }
| { Ok(vec![]) }
;
Scopes -> Result<Vec<Span>, ()>:
Scopes "," "STRING" {
let mut spans = $1?;
spans.push(map_err($3)?);
Ok(spans)
}
| "STRING" { Ok(vec![map_err($1)?]) }
| { Ok(vec![]) }
;
// This rule helps turn lexing errors into parsing errors.
Unmatched -> ():
"UNMATCHED" { }
;
%%
use lrlex::DefaultLexeme;
use lrpar::Span;
type StorageT = u8;
use crate::config_ast::{AccountField, TopLevel};
fn map_err(r: Result<DefaultLexeme<StorageT>, DefaultLexeme<StorageT>>)
-> Result<Span, ()>
{
r.map(|x| x.span()).map_err(|_| ())
}
/// Flatten `rhs` into `lhs`.
fn flattenr<T>(lhs: Result<Vec<T>, ()>, rhs: Result<T, ()>) -> Result<Vec<T>, ()> {
let mut flt = lhs?;
flt.push(rhs?);
Ok(flt)
}
================================================
FILE: src/config_ast.rs
================================================
use lrpar::Span;
pub enum TopLevel {
Account(Span, Span, Vec<AccountField>),
AuthErrorCmd(Span),
AuthNotifyCmd(Span),
AuthNotifyInterval(Span),
ErrorNotifyCmd(Span),
HttpListen(Span),
HttpListenNone(Span),
HttpsListen(Span),
HttpsListenNone(Span),
TransientErrorIfCmd(Span),
RefreshAtLeast(Span),
RefreshBeforeExpiry(Span),
RefreshRetry(Span),
StartupCmd(Span),
TokenEventCmd(Span),
}
pub enum AccountField {
AuthUri(Span),
AuthUriFields(Span, Vec<(Span, Span)>),
ClientId(Span),
ClientSecret(Span),
LoginHint(Span),
RedirectUri(Span),
RefreshAtLeast(Span),
RefreshBeforeExpiry(Span),
RefreshRetry(Span),
Scopes(Span, Vec<Span>),
TokenUri(Span),
}
================================================
FILE: src/main.rs
================================================
#![allow(clippy::derive_partial_eq_without_eq)]
#![allow(clippy::too_many_arguments)]
#![allow(clippy::type_complexity)]
mod compat;
mod config;
mod config_ast;
mod server;
mod shell_cmd;
mod user_sender;
use std::{
env::{self, current_exe},
fs,
io::{stdout, Write},
os::unix::{fs::PermissionsExt, net::UnixStream},
path::PathBuf,
process, thread,
time::Duration,
};
use getopts::Options;
use log::error;
use nix::{
fcntl::AT_FDCWD,
sys::{
stat::{utimensat, UtimensatFlags},
time::TimeSpec,
},
};
#[cfg(target_os = "openbsd")]
use pledge::pledge;
use serde_json::json;
use server::sock_path;
use whoami::username;
use compat::daemon;
use config::Config;
use user_sender::show_token;
/// Name of cache directory within $XDG_DATA_HOME.
const PIZAUTH_CACHE_LEAF: &str = "pizauth";
/// Name of socket file within $XDG_DATA_HOME/PIZAUTH_CACHE_LEAF.
const PIZAUTH_CACHE_SOCK_LEAF: &str = "pizauth.sock";
/// Name of `pizauth.conf` file relative to $XDG_CONFIG_HOME.
const PIZAUTH_CONF_LEAF: &str = "pizauth.conf";
fn progname() -> String {
match current_exe() {
Ok(p) => p
.file_name()
.map(|x| x.to_str().unwrap_or("pizauth"))
.unwrap_or("pizauth")
.to_owned(),
Err(_) => "pizauth".to_owned(),
}
}
/// Exit with a fatal error: only to be called before the log crate is setup.
fn fatal(msg: &str) -> ! {
eprintln!("{msg:}");
process::exit(1);
}
/// Print out program usage then exit. This function must not be called after daemonisation.
fn usage() -> ! {
let pn = progname();
eprintln!(
"Usage:\n {pn:} dump\n {pn:} info [-j]\n {pn:} refresh [-u] <account>\n {pn:} restore\n {pn:} reload\n {pn:} revoke <account>\n {pn:} server [-c <config-path>] [-dv]\n {pn:} show [-u] <account>\n {pn:} shutdown\n {pn:} status"
);
process::exit(1)
}
fn cache_path() -> PathBuf {
let mut p = PathBuf::new();
match env::var_os("XDG_RUNTIME_DIR") {
Some(s) => p.push(s),
None => {
match env::var_os("TMPDIR") {
Some(s) => p.push(s),
None => p.push("/tmp"),
}
p.push(format!(
"runtime-{}",
username().unwrap_or_else(|_| "unknown-user".to_owned())
));
}
}
let md = |p: &PathBuf| {
if !p.exists() {
fs::create_dir(p).unwrap_or_else(|e| fatal(&format!("Can't create cache dir: {e}")));
}
fs::set_permissions(p, PermissionsExt::from_mode(0o700)).unwrap_or_else(|_| {
fatal(&format!(
"Can't set permissions for {} to 0700 (octal)",
p.to_str()
.unwrap_or("<path cannot be represented as UTF-8>")
))
});
};
md(&p);
p.push(PIZAUTH_CACHE_LEAF);
md(&p);
p
}
fn conf_path(matches: &getopts::Matches) -> PathBuf {
match matches.opt_str("c") {
Some(p) => PathBuf::from(&p),
None => {
let mut p = PathBuf::new();
match env::var_os("XDG_CONFIG_HOME") {
Some(s) => p.push(s),
None => match env::var_os("HOME") {
Some(s) => {
p.push(s);
p.push(".config")
}
None => fatal("Neither $XDG_CONFIG_HOME or $HOME set"),
},
}
p.push(PIZAUTH_CONF_LEAF);
if !p.is_file() {
fatal(&format!(
"No config file found at {}",
p.to_str().unwrap_or("pizauth.conf")
));
}
p
}
}
}
fn main() {
// Generic pledge support for all pizauth's commands. Note that the server later restricts
// these further.
#[cfg(target_os = "openbsd")]
pledge(
"stdio rpath wpath cpath inet fattr flock unix dns proc ps exec unveil",
None,
)
.unwrap();
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
usage();
}
let mut opts = Options::new();
opts.optflag("h", "help", "")
.optflagmulti("v", "verbose", "");
let cache_path = cache_path();
match args[1].as_str() {
"dump" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
match user_sender::dump(&cache_path) {
Ok(d) => {
stdout().write_all(&d).ok();
}
Err(e) => {
error!("{e:}");
process::exit(1);
}
}
}
"info" => {
let matches = opts
.optflagopt("c", "config", "Path to pizauth.conf.", "<conf-path>")
.optflag("j", "", "JSON output.")
.parse(&args[2..])
.unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
let svj = user_sender::server_info(&cache_path).ok();
let progname = progname();
let cache_path = cache_path
.to_str()
.unwrap_or("<path cannot be represented as UTF-8>");
let conf_path = conf_path(&matches);
let conf_path = conf_path
.to_str()
.unwrap_or("<path cannot be represented as UTF-8>");
let ver = env!("CARGO_PKG_VERSION");
if matches.opt_present("j") {
let mut j = json!({
"cache_directory": cache_path,
"config_file": conf_path,
"executed_as": progname,
"info_format_version": 2,
"pizauth_version": ver
});
let svj = match svj {
Some(x) => json!({ "server_running": true, "server_info": x}),
None => json!({ "server_running": false }),
};
j.as_object_mut()
.unwrap()
.extend(svj.as_object().unwrap().clone());
println!("{}", j);
} else {
println!("{progname} version {ver}:\n cache directory: {cache_path}\n config file: {conf_path}");
if let Some(svj) = svj {
println!(
"server running:\n HTTP port: {}\n HTTPS port: {}",
svj["http_port"].as_str().unwrap(),
svj["https_port"].as_str().unwrap()
);
if let Some(x) = svj.get("https_pub_key") {
println!(" HTTPS public key: {}", x.as_str().unwrap());
}
} else {
println!("server not running");
}
}
}
"refresh" => {
let matches = opts
.optflag("u", "", "Don't display authorisation URLs.")
.parse(&args[2..])
.unwrap_or_else(|_| usage());
if matches.opt_present("h") || matches.free.len() != 1 {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
let with_url = !matches.opt_present("u");
if let Err(e) = user_sender::refresh(&cache_path, &matches.free[0], with_url) {
error!("{e:}");
process::exit(1);
}
}
"reload" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
if let Err(e) = user_sender::reload(&cache_path) {
error!("{e:}");
process::exit(1);
}
}
"restore" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
if let Err(e) = user_sender::restore(&cache_path) {
error!("{e:}");
process::exit(1);
}
}
"revoke" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") || matches.free.len() != 1 {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
if let Err(e) = user_sender::revoke(&cache_path, &matches.free[0]) {
error!("{e:}");
process::exit(1);
}
}
"server" => {
let matches = opts
.optflagopt("c", "config", "Path to pizauth.conf.", "<conf-path>")
.optflag("d", "", "Don't detach from the terminal.")
.parse(&args[2..])
.unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
let sock_path = sock_path(&cache_path);
if sock_path.exists() {
// Is an existing authenticator running?
if UnixStream::connect(&sock_path).is_ok() {
eprintln!("pizauth authenticator already running");
process::exit(1);
}
fs::remove_file(&sock_path).ok();
}
// The XDG spec says of `$XDG_RUNTIME_DIR` (where our socket file will live):
// Files in this directory MAY be subjected to periodic clean-up. To ensure that your files
// are not removed, they should have their access time timestamp modified at least once every
// 6 hours of monotonic time
let sock_path_cl = sock_path.clone();
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(6 * 60 * 60));
let _ = utimensat(
AT_FDCWD,
&sock_path_cl,
&TimeSpec::UTIME_NOW,
&TimeSpec::UTIME_NOW,
UtimensatFlags::NoFollowSymlink,
);
});
let conf_path = conf_path(&matches);
let conf = Config::from_path(&conf_path).unwrap_or_else(|m| fatal(&m));
let daemonise = !matches.opt_present("d");
if daemonise {
let formatter = syslog::Formatter3164 {
process: progname(),
..Default::default()
};
let logger = syslog::unix(formatter)
.unwrap_or_else(|e| fatal(&format!("Cannot connect to syslog: {e:}")));
let levelfilter = match matches.opt_count("v") {
0 => log::LevelFilter::Error,
1 => log::LevelFilter::Warn,
2 => log::LevelFilter::Info,
3 => log::LevelFilter::Debug,
_ => log::LevelFilter::Trace,
};
log::set_boxed_logger(Box::new(syslog::BasicLogger::new(logger)))
.map(|()| log::set_max_level(levelfilter))
.unwrap_or_else(|e| fatal(&format!("Cannot set logger: {e:}")));
daemon(true, false).unwrap_or_else(|e| fatal(&format!("Cannot daemonise: {e:}")));
} else {
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
}
if let Err(e) = server::server(conf_path, conf, cache_path.as_path()) {
error!("{e:}");
process::exit(1);
}
}
"show" => {
let matches = opts
.optflag("u", "", "Don't display authorisation URLs.")
.parse(&args[2..])
.unwrap_or_else(|_| usage());
if matches.opt_present("h") {
usage();
}
if matches.free.len() != 1 {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
let account = matches.free[0].as_str();
if let Err(e) = show_token(cache_path.as_path(), account, !matches.opt_present("u")) {
error!("{e:}");
process::exit(1);
}
}
"shutdown" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") || !matches.free.is_empty() {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
if let Err(e) = user_sender::shutdown(&cache_path) {
error!("{e:}");
process::exit(1);
}
}
"status" => {
let matches = opts.parse(&args[2..]).unwrap_or_else(|_| usage());
if matches.opt_present("h") {
usage();
}
if !matches.free.is_empty() {
usage();
}
stderrlog::new()
.module(module_path!())
.verbosity(matches.opt_count("v"))
.init()
.unwrap();
if let Err(e) = user_sender::status(cache_path.as_path()) {
error!("{e:}");
process::exit(1);
}
}
_ => usage(),
}
}
================================================
FILE: src/server/eventer.rs
================================================
use std::{
collections::VecDeque,
error::Error,
fmt::{self, Display, Formatter},
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
use log::error;
use crate::{server::AuthenticatorState, shell_cmd::shell_cmd};
/// How long to run `token_event_cmd`s before killing them?
const TOKEN_EVENT_CMD_TIMEOUT: Duration = Duration::from_secs(10);
pub enum TokenEvent {
Invalidated,
New,
Refresh,
Revoked,
}
impl Display for TokenEvent {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
TokenEvent::Invalidated => write!(f, "token_invalidated"),
TokenEvent::New => write!(f, "token_new"),
TokenEvent::Refresh => write!(f, "token_refreshed"),
TokenEvent::Revoked => write!(f, "token_revoked"),
}
}
}
pub struct Eventer {
pred: Mutex<bool>,
condvar: Condvar,
event_queue: Mutex<VecDeque<(String, TokenEvent)>>,
}
impl Eventer {
pub fn new() -> Result<Self, Box<dyn Error>> {
Ok(Eventer {
pred: Mutex::new(false),
condvar: Condvar::new(),
event_queue: Mutex::new(VecDeque::new()),
})
}
pub fn eventer(self: Arc<Self>, pstate: Arc<AuthenticatorState>) -> Result<(), Box<dyn Error>> {
thread::spawn(move || loop {
let mut eventer_lk = self.pred.lock().unwrap();
while !*eventer_lk {
eventer_lk = self.condvar.wait(eventer_lk).unwrap();
}
*eventer_lk = false;
drop(eventer_lk);
loop {
let (act_name, event) =
if let Some((act_name, event)) = self.event_queue.lock().unwrap().pop_front() {
(act_name, event)
} else {
break;
};
let token_event_cmd = if let Some(token_event_cmd) =
pstate.ct_lock().config().token_event_cmd.clone()
{
token_event_cmd
} else {
break;
};
if let Err(e) = shell_cmd(
&token_event_cmd,
[
("PIZAUTH_ACCOUNT", act_name.as_str()),
("PIZAUTH_EVENT", &event.to_string()),
],
TOKEN_EVENT_CMD_TIMEOUT,
) {
error!("{e}");
}
}
});
Ok(())
}
pub fn token_event(&self, act_name: String, kind: TokenEvent) {
self.event_queue.lock().unwrap().push_back((act_name, kind));
let mut event_lk = self.pred.lock().unwrap();
*event_lk = true;
self.condvar.notify_one();
}
}
================================================
FILE: src/server/http_server.rs
================================================
use std::{
error::Error,
io::{BufRead, BufReader, Read, Write},
net::TcpListener,
sync::Arc,
thread,
time::Duration,
};
use boot_time::Instant;
use log::warn;
use serde_json::Value;
use url::Url;
use rcgen::{generate_simple_self_signed, CertifiedKey, KeyPair};
use rustls::{
pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer},
ServerConfig,
};
use super::{
eventer::TokenEvent, expiry_instant, AccountId, AuthenticatorState, Config, TokenState,
UREQ_TIMEOUT,
};
/// How often should we try making a request to an OAuth server for possibly-temporary transport
/// issues?
const RETRY_POST: u8 = 10;
/// How long to delay between each retry?
const RETRY_DELAY: u64 = 6;
/// What is the maximum HTTP request size, in bytes, we allow? We are less worried about malicious
/// actors than we are about malfunctioning systems. We thus set this to a far higher value than we
/// actually expect to see in practise: if any client connecting exceeds this, they've probably got
/// real problems!
const MAX_HTTP_REQUEST_SIZE: usize = 16 * 1024;
/// Handle an incoming (hopefully OAuth2) HTTP request.
fn request<T: Read + Write>(
pstate: Arc<AuthenticatorState>,
mut stream: T,
is_https: bool,
) -> Result<(), Box<dyn Error>> {
// This function is split into two halves. In the first half, we process the incoming HTTP
// request: if there's a problem, it (mostly) means the request is mal-formed or stale, and
// there's no effect on the tokenstate. In the second half we make a request to an OAuth
// server: if there's a problem, we have to reset the tokenstate and force the user to make an
// entirely fresh request.
let uri = match parse_get(&mut stream, is_https) {
Ok(x) => x,
Err(_) => {
// If someone couldn't even be bothered giving us a valid URI, it's unlikely this was a
// genuine request that's worth reporting as an error.
http_400(stream);
return Ok(());
}
};
// All valid requests (even those reporting an error!) should report back a valid "state" to
// us, so fish that out of the URI and check that it matches a request we made.
let state = match uri.query_pairs().find(|(k, _)| k == "state") {
Some((_, state)) => state.into_owned(),
None => {
// As well as malformed OAuth queries this will also 404 for favicon.ico.
http_404(stream);
return Ok(());
}
};
let mut ct_lk = pstate.ct_lock();
let act_id = match ct_lk.act_id_matching_token_state(&state) {
Some(x) => x,
None => {
drop(ct_lk);
http_200(
stream,
"No pending token matches request state: request a fresh token",
);
return Ok(());
}
};
// Now that we know which account has been matched we can check if the full URI requested
// matched the redirect URI we expected for that account.
let act = ct_lk.account(act_id);
let expected_uri = act.redirect_uri(pstate.http_port, pstate.https_port)?;
if !redirect_uri_matches(&expected_uri, &uri) {
// If the redirect URI doesn't match then all we can do is 404.
drop(ct_lk);
http_404(stream);
return Ok(());
}
// Did authentication fail?
if let Some((_, reason)) = uri.query_pairs().find(|(k, _)| k == "error") {
let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);
let act_name = ct_lk.account(act_id).name.clone();
let msg = format!(
"Authentication for {} failed: {}",
ct_lk.account(act_id).name,
reason
);
drop(ct_lk);
http_400(stream);
pstate.notifier.notify_error(&pstate, act_name, msg)?;
return Ok(());
}
// Fish out the code query.
let code = match uri.query_pairs().find(|(k, _)| k == "code") {
Some((_, code)) => code.to_string(),
None => {
// A request without a 'code' is broken. This seems very unlikely to happen and if it
// does, would retrying our request from scratch improve anything?
drop(ct_lk);
http_400(stream);
return Ok(());
}
};
let code_verifier = match ct_lk.tokenstate(act_id) {
TokenState::Pending {
ref code_verifier, ..
} => code_verifier.clone(),
_ => unreachable!(),
};
let token_uri = act.token_uri.clone();
let client_id = act.client_id.clone();
let redirect_uri = act
.redirect_uri(pstate.http_port, pstate.https_port)?
.to_string();
let mut pairs = vec![
("code", code.as_str()),
("client_id", client_id.as_str()),
("code_verifier", code_verifier.as_str()),
("redirect_uri", redirect_uri.as_str()),
("grant_type", "authorization_code"),
];
let client_secret = act.client_secret.clone();
if let Some(ref x) = client_secret {
pairs.push(("client_secret", x));
}
// At this point we know we've got a sensible looking query, so we complete the HTTP request,
// because we don't know how long we'll spend going through the rest of the OAuth process, and
// we can notify the user another way than through their web browser.
drop(ct_lk);
http_200(
stream,
"pizauth processing authentication: you can safely close this page.",
);
// Try moderately hard to deal with temporary network errors and the like, but assume that any
// request that partially makes a connection but does not then fully succeed is an error (since
// we can't reuse authentication codes), and we'll have to start again entirely.
let mut body = None;
let agent_conf = ureq::Agent::config_builder()
.timeout_global(Some(UREQ_TIMEOUT))
.build();
for _ in 0..RETRY_POST {
match ureq::Agent::new_with_config(agent_conf.clone())
.post(token_uri.as_str())
.send_form(pairs.clone())
{
Ok(response) => {
if let Ok(s) = response.into_body().read_to_string() {
body = Some(s);
break;
}
}
Err(ureq::Error::StatusCode(code)) => {
let reason = format!("HTTP code {code}");
fail(pstate, act_id, &reason)?;
return Ok(());
}
Err(_) => (), // Temporary network error or the like
}
thread::sleep(Duration::from_secs(RETRY_DELAY));
}
let body = match body {
Some(x) => x,
None => {
fail(pstate, act_id, &format!("couldn't connect to {token_uri:}"))?;
return Ok(());
}
};
let parsed = match serde_json::from_str::<Value>(&body) {
Ok(x) => x,
Err(e) => {
fail(pstate, act_id, &format!("Invalid JSON: {e}"))?;
return Ok(());
}
};
let mut ct_lk = pstate.ct_lock();
if !ct_lk.is_act_id_valid(act_id) {
return Ok(());
}
if let Some(err_msg) = parsed["error"].as_str() {
drop(ct_lk);
fail(pstate, act_id, err_msg)?;
return Ok(());
}
match (
parsed["token_type"].as_str(),
parsed["expires_in"].as_u64(),
parsed["access_token"].as_str(),
parsed["refresh_token"].as_str(),
) {
(Some("Bearer"), Some(expires_in), Some(access_token), refresh_token) => {
let now = Instant::now();
let expiry = expiry_instant(&ct_lk, act_id, now, expires_in)?;
let act_name = ct_lk.account(act_id).name.to_owned();
ct_lk.tokenstate_replace(
act_id,
TokenState::Active {
access_token: access_token.to_owned(),
access_token_obtained: now,
access_token_expiry: expiry,
ongoing_refresh: false,
consecutive_refresh_fails: 0,
last_refresh_attempt: None,
refresh_token: refresh_token.map(|x| x.to_owned()),
},
);
drop(ct_lk);
pstate.refresher.notify_changes();
pstate.eventer.token_event(act_name, TokenEvent::New);
}
_ => {
drop(ct_lk);
fail(pstate, act_id, "invalid response received")?;
}
}
Ok(())
}
/// If a request to an OAuth server has failed then notify the user of that failure and mark the
/// tokenstate as [TokenState::Empty] unless the config has changed or the user has initiated a new
/// request while we've been trying (unsuccessfully) with the OAuth server.
fn fail(
pstate: Arc<AuthenticatorState>,
act_id: AccountId,
msg: &str,
) -> Result<(), Box<dyn Error>> {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
// It's possible -- though admittedly unlikely -- that another thread has managed to grab
// an `Active` token so we have to handle the possibility.
let is_active = matches!(ct_lk.tokenstate(act_id), TokenState::Active { .. });
let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);
let act_name = ct_lk.account(act_id).name.clone();
let msg = format!(
"Authentication for {} failed: {msg:}",
ct_lk.account(act_id).name
);
drop(ct_lk);
pstate
.notifier
.notify_error(&pstate, act_name.clone(), msg)?;
if is_active {
pstate
.eventer
.token_event(act_name, TokenEvent::Invalidated);
}
}
Ok(())
}
/// A very literal, and rather unforgiving, implementation of RFC2616 (HTTP/1.1), returning the URL
/// of GET requests: returns `Err` for anything else.
fn parse_get<T: Read + Write>(stream: &mut T, is_https: bool) -> Result<Url, Box<dyn Error>> {
let mut rdr = BufReader::new(stream);
let mut req_line = String::new();
rdr.read_line(&mut req_line)?;
let mut http_req_size = req_line.len();
// First the request line:
// Request-Line = Method SP Request-URI SP HTTP-Version CRLF
// where Method = "GET" and `SP` is a single space character.
let req_line_sp = req_line.split(' ').collect::<Vec<_>>();
if !matches!(req_line_sp.as_slice(), &["GET", _, _]) {
return Err("Malformed HTTP request".into());
}
let path = req_line_sp[1];
// Consume rest of HTTP request
let mut req: Vec<String> = Vec::new();
loop {
if http_req_size >= MAX_HTTP_REQUEST_SIZE {
return Err("HTTP request exceeds maximum permitted size".into());
}
let mut line = String::new();
rdr.read_line(&mut line)?;
if line.as_str().trim().is_empty() {
break;
}
http_req_size += line.len();
match line.chars().next() {
Some(' ') | Some('\t') => {
// Continuation of previous header
match req.last_mut() {
Some(x) => {
// Not calling `trim_start` means that the two joined lines have at least
// one space|tab between them.
x.push_str(line.as_str().trim_end());
}
None => return Err("Malformed HTTP header".into()),
}
}
_ => req.push(line.as_str().trim_end().to_owned()),
}
}
// Find the host field.
let mut host = None;
for f in req {
// Fields are a case insensitive name, followed by a colon, then zero or more tabs/spaces,
// and then the value.
if let Some(i) = f.as_str().find(':') {
if f.as_str()[..i].eq_ignore_ascii_case("host") {
if host.is_some() {
// Fields can be repeated, but that doesn't make sense for "host"
return Err("Repeated 'host' field in HTTP header".into());
}
let j: usize = f[i + ':'.len_utf8()..]
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.map(|c| c.len_utf8())
.sum();
host = Some(f[i + ':'.len_utf8() + j..].to_string());
}
}
}
// If host is Some, use addressed port to select scheme (http / https)
// This works, as no HTTPS request will arrive until here on the HTTP port and vice versa
match host {
Some(h) => Url::parse(&format!(
"{}://{h:}{path:}",
if is_https { "https" } else { "http" }
))
.map_err(|e| format!("Invalid request URI: {e:}").into()),
None => Err("No host field specified in HTTP request".into()),
}
}
fn http_200<T: Read + Write>(mut stream: T, body: &str) {
stream
.write_all(
format!("HTTP/1.1 200 OK\r\n\r\n<html><body><h2>{body}</h2></body></html>").as_bytes(),
)
.ok();
}
fn http_404<T: Read + Write>(mut stream: T) {
stream.write_all(b"HTTP/1.1 404\r\n\r\n").ok();
}
fn http_400<T: Read + Write>(mut stream: T) {
stream.write_all(b"HTTP/1.1 400\r\n\r\n").ok();
}
/// Return `true` if `actual` matches `expected` or `false` otherwise.
fn redirect_uri_matches(expected: &Url, actual: &Url) -> bool {
assert!(expected.fragment().is_none());
if expected.scheme() != actual.scheme()
|| expected.host_str() != actual.host_str()
|| expected.port() != actual.port()
|| expected.path() != actual.path()
|| actual.fragment().is_some()
{
return false;
}
let actual_pairs = actual.query_pairs().collect::<Vec<_>>();
for x in expected.query_pairs() {
if !actual_pairs.contains(&x) {
return false;
}
}
true
}
pub fn http_server_setup(conf: &Config) -> Result<Option<(u16, TcpListener)>, Box<dyn Error>> {
// Bind TCP port for HTTP
match &conf.http_listen {
Some(http_listen) => {
let listener = TcpListener::bind(http_listen)?;
Ok(Some((listener.local_addr()?.port(), listener)))
}
None => Ok(None),
}
}
pub fn http_server(
pstate: Arc<AuthenticatorState>,
listener: TcpListener,
) -> Result<(), Box<dyn Error>> {
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let pstate = Arc::clone(&pstate);
thread::spawn(|| {
if let Err(e) = request(pstate, stream, false) {
warn!("{e:}");
}
});
}
});
Ok(())
}
pub fn https_server_setup(
conf: &Config,
) -> Result<Option<(u16, TcpListener, CertifiedKey<KeyPair>)>, Box<dyn Error>> {
match &conf.https_listen {
Some(https_listen) => {
// Set a process wide default crypto provider.
let _ = rustls::crypto::ring::default_provider().install_default();
// Generate self-signed certificate
let mut names = vec![
String::from("localhost"),
String::from("127.0.0.1"),
String::from("::1"),
];
if let Ok(x) = hostname::get() {
if let Some(x) = x.to_str() {
names.push(String::from(x));
}
}
let cert = generate_simple_self_signed(names)?;
// Bind TCP port for HTTPS
let listener = TcpListener::bind(https_listen)?;
Ok(Some((listener.local_addr()?.port(), listener, cert)))
}
None => Ok(None),
}
}
pub fn https_server(
pstate: Arc<AuthenticatorState>,
listener: TcpListener,
cert: CertifiedKey<KeyPair>,
) -> Result<(), Box<dyn Error>> {
// Build TLS configuration.
let mut server_config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![cert.cert.into()],
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())),
)
.map_err(|e| e.to_string())?;
// Negotiate application layer protocols: Only HTTP/1.1 is allowed
server_config.alpn_protocols = vec![b"http/1.1".to_vec()];
thread::spawn(move || {
for mut stream in listener.incoming().flatten() {
// generate a new TLS connection
let conn = rustls::ServerConnection::new(Arc::new(server_config.clone()));
if let Err(e) = conn {
warn!("{e:}");
continue;
}
let mut conn = conn.unwrap();
let pstate = Arc::clone(&pstate);
thread::spawn(move || {
// convert TCP stream into TLS stream
let stream = rustls::Stream::new(&mut conn, &mut stream);
if let Err(e) = request(pstate, stream, true) {
warn!("{e:}");
}
});
}
});
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn redirect_uri_matching() {
fn t(expected: &str, actual: &str) -> bool {
redirect_uri_matches(&Url::parse(expected).unwrap(), &Url::parse(actual).unwrap())
}
assert!(t("http://a.com/b", "http://a.com/b"));
assert!(!t("http://a.com/b", "http://b.com/b"));
assert!(!t("http://a.com/b", "http://a.com/c"));
assert!(t("http://a.com:1234/b", "http://a.com:1234/b"));
assert!(!t("http://a.com:1234/b", "http://a.com:123/b"));
assert!(t("http://a.com/b?c=d", "http://a.com/b?c=d"));
assert!(!t("http://a.com:1234/b?c=d", "http://a.com:1234/b"));
assert!(t("http://a.com/b", "http://a.com/b?c=d"));
assert!(!t("http://a.com/b", "http://a.com/b#c"));
}
}
================================================
FILE: src/server/mod.rs
================================================
mod eventer;
mod http_server;
mod notifier;
mod refresher;
mod request_token;
mod state;
use std::{
collections::HashMap,
env,
error::Error,
io::{Read, Write},
os::unix::net::{UnixListener, UnixStream},
path::{Path, PathBuf},
process::Command,
sync::Arc,
thread,
time::{Duration, SystemTime},
};
use boot_time::Instant;
use chrono::{DateTime, Local};
use log::{error, warn};
use nix::sys::signal::{raise, Signal};
#[cfg(target_os = "openbsd")]
use pledge::pledge;
#[cfg(target_os = "openbsd")]
use unveil::unveil;
use crate::{config::Config, PIZAUTH_CACHE_SOCK_LEAF};
use eventer::{Eventer, TokenEvent};
use notifier::Notifier;
use refresher::Refresher;
use request_token::request_token;
use serde_json::json;
use state::{AccountId, AuthenticatorState, CTGuard, TokenState};
/// Length of the PKCE code verifier in bytes.
const CODE_VERIFIER_LEN: usize = 64;
/// The timeout for ureq HTTP requests. It is recommended to make this value lower than
/// REFRESH_RETRY_DEFAULT to reduce the likelihood that refresh requests overlap.
pub const UREQ_TIMEOUT: Duration = Duration::from_secs(30);
/// Length of the OAuth "state" in bytes: this is a string we send when requesting a token that is
/// echoed back to us, allowing us to distinguish different request. There's no fixed size for
/// this, and indeed one can go perhaps up to at least a kilobyte, but that's probably not going to
/// give us useful additional security, and it makes request URLs even longer.
const STATE_LEN: usize = 32;
/// When waiting to do something (e.g. in the notifier or refresher), we have the problem that when
/// we ask to be woken up in "X seconds from now", operating systems do not interpret that as "wake
/// you up in X seconds of wall-clock time". For example, if a machine is suspended then resumed,
/// then the time the machine was out of action may not be counted as "wait time". The impact of
/// ntp/adjtime and friends is also unclear. There is no portable way for us to know if any of
/// these things has happened, so we are left in the unhappy situation that if a thread knows it
/// has work to do in the future, it needs to wake itself up every so often to check if -- without
/// us knowing it! -- the clock has changed underneath it.
///
/// There is no universally good value here. Too short means that we waste resources; too long and
/// the user will think that we have gone wrong; too predictable and we might end up causing weird
/// spikes in performance (e.g. if we wake up exactly every 10/30/60 seconds). To make problems
/// even less likely, we choose a prime number.
const MAX_WAIT_SECS: u64 = 37;
pub fn sock_path(cache_path: &Path) -> PathBuf {
let mut p = cache_path.to_owned();
p.push(PIZAUTH_CACHE_SOCK_LEAF);
p
}
/// Calculate the [Instant] that a token will expire at. Returns `Err` if [Instant] cannot
/// represent the expiry.
pub fn expiry_instant(
ct_lk: &CTGuard,
act_id: AccountId,
refreshed_at: Instant,
expires_in: u64,
) -> Result<Instant, Box<dyn Error>> {
refreshed_at
.checked_add(Duration::from_secs(expires_in))
.or_else(|| {
refreshed_at.checked_add(ct_lk.account(act_id).refresh_at_least(ct_lk.config()))
})
.ok_or_else(|| "Can't represent expiry".into())
}
fn request(pstate: Arc<AuthenticatorState>, mut stream: UnixStream) -> Result<(), Box<dyn Error>> {
let mut buf = Vec::new();
stream.read_to_end(&mut buf)?;
let (cmd, rest) = {
let len = buf
.iter()
.map(|b| *b as char)
.take_while(|c| *c != ':')
.count();
if len == buf.len() {
return Err(format!(
"Syntactically invalid request '{}'",
std::str::from_utf8(&buf).unwrap_or("<can't represent as UTF-8")
)
.into());
}
(std::str::from_utf8(&buf[..len])?, &buf[len + 1..])
};
match cmd {
"dump" if rest.is_empty() => {
stream.write_all(&pstate.dump()?)?;
return Ok(());
}
"info" if rest.is_empty() => {
let mut m = HashMap::new();
m.insert(
"http_port",
match pstate.http_port {
Some(x) => x.to_string(),
None => "none".to_string(),
},
);
m.insert(
"https_port",
match pstate.https_port {
Some(x) => x.to_string(),
None => "none".to_string(),
},
);
if let Some(x) = &pstate.https_pub_key {
m.insert("https_pub_key", x.clone());
}
stream.write_all(json!(m).to_string().as_bytes())?;
return Ok(());
}
"reload" if rest.is_empty() => {
match Config::from_path(&pstate.conf_path) {
Ok(new_conf) => {
pstate.update_conf(new_conf);
stream.write_all(b"ok:")?
}
Err(e) => stream.write_all(format!("error:{e:}").as_bytes())?,
}
return Ok(());
}
"refresh" => {
let rest = std::str::from_utf8(rest)?;
if let [with_url, act_name] = &rest.splitn(2, ' ').collect::<Vec<_>>()[..] {
let ct_lk = pstate.ct_lock();
let act_id = match ct_lk.validate_act_name(act_name) {
Some(x) => x,
None => {
drop(ct_lk);
stream.write_all(format!("error:No account '{act_name:}'").as_bytes())?;
return Ok(());
}
};
match ct_lk.tokenstate(act_id) {
TokenState::Empty | TokenState::Pending { .. } => {
let url = request_token(Arc::clone(&pstate), ct_lk, act_id)?;
if *with_url == "withurl" {
stream.write_all(format!("pending:{url:}").as_bytes())?;
} else {
stream.write_all(b"pending:")?;
}
}
TokenState::Active { .. } => {
drop(ct_lk);
pstate.refresher.sched_refresh(Arc::clone(&pstate), act_id);
stream.write_all(b"scheduled:")?;
}
}
return Ok(());
}
}
"restore" => {
match pstate.restore(rest.to_vec()) {
Ok(_) => stream.write_all(b"ok:")?,
Err(e) => stream.write_all(format!("error:{e:}").as_bytes())?,
}
return Ok(());
}
"revoke" => {
let act_name = std::str::from_utf8(rest)?;
let mut ct_lk = pstate.ct_lock();
match ct_lk.validate_act_name(act_name) {
Some(act_id) => {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
drop(ct_lk);
pstate
.eventer
.token_event(act_name.to_owned(), TokenEvent::Revoked);
stream.write_all(b"ok:")?;
return Ok(());
}
None => {
drop(ct_lk);
stream.write_all(format!("error:No account '{act_name:}'").as_bytes())?;
return Ok(());
}
};
}
"showtoken" => {
let rest = std::str::from_utf8(rest)?;
if let [with_url, act_name] = &rest.splitn(2, ' ').collect::<Vec<_>>()[..] {
let ct_lk = pstate.ct_lock();
let act_id = match ct_lk.validate_act_name(act_name) {
Some(x) => x,
None => {
drop(ct_lk);
stream.write_all(format!("error:No account '{act_name:}'").as_bytes())?;
return Ok(());
}
};
match ct_lk.tokenstate(act_id) {
TokenState::Empty => {
let url = request_token(Arc::clone(&pstate), ct_lk, act_id)?;
if *with_url == "withurl" {
stream.write_all(format!("pending:{url:}").as_bytes())?;
} else {
stream.write_all(b"pending:")?;
}
}
TokenState::Pending { ref url, .. } => {
let response = if *with_url == "withurl" {
format!("pending:{url:}")
} else {
"pending:".to_owned()
};
drop(ct_lk);
stream.write_all(response.as_bytes())?;
}
TokenState::Active {
access_token,
access_token_expiry,
ongoing_refresh,
..
} => {
let response = if access_token_expiry > &Instant::now() {
format!("access_token:{access_token:}")
} else if *ongoing_refresh {
"error:Access token has expired. Refreshing is in progress but has not yet succeeded"
.into()
} else {
pstate.refresher.sched_refresh(Arc::clone(&pstate), act_id);
"error:Access token has expired. Refreshing initiated".into()
};
drop(ct_lk);
stream.write_all(response.as_bytes())?;
}
}
return Ok(());
}
}
"shutdown" if rest.is_empty() => {
raise(Signal::SIGTERM).ok();
return Ok(());
}
"status" if rest.is_empty() => {
let ct_lk = pstate.ct_lock();
let mut acts = Vec::new();
for act_id in ct_lk.act_ids() {
let act = ct_lk.account(act_id);
let st = match ct_lk.tokenstate(act_id) {
TokenState::Empty => "No access token".into(),
TokenState::Pending {
last_notification: Some(i),
..
} => format!(
"Access token pending authentication (last notification {})",
instant_fmt(*i)
),
TokenState::Pending {
last_notification: None,
..
} => "Access token pending authentication".into(),
TokenState::Active {
access_token_obtained,
access_token_expiry,
last_refresh_attempt,
..
} => {
if *access_token_expiry > Instant::now() {
format!(
"Active access token (obtained {}; expires {})",
instant_fmt(*access_token_obtained),
instant_fmt(*access_token_expiry)
)
} else if let Some(i) = last_refresh_attempt {
format!(
"Access token expired (last refresh attempt {})",
instant_fmt(*i)
)
} else {
"Access token expired (refresh not yet attempted)".into()
}
}
};
acts.push(format!("{}: {st}", act.name));
}
acts.sort();
if acts.is_empty() {
stream.write_all(b"error:No accounts configured")?;
} else {
stream.write_all(format!("ok:{}", acts.join("\n")).as_bytes())?;
}
return Ok(());
}
x => stream.write_all(format!("error:Unknown command '{x}'").as_bytes())?,
}
Err("Invalid command".into())
}
/// Attempt to print an [Instant] as a user-readable string. By the very nature of [Instant]s,
/// there is no guarantee this is possible or that the time presented is accurate.
fn instant_fmt(i: Instant) -> String {
let now = Instant::now();
if i < now {
if let Some(d) = now.checked_duration_since(i) {
if let Some(st) = SystemTime::now().checked_sub(d) {
let dt: DateTime<Local> = st.into();
return dt.to_rfc2822();
}
}
} else if let Some(d) = i.checked_duration_since(now) {
if let Some(st) = SystemTime::now().checked_add(d) {
let dt: DateTime<Local> = st.into();
return dt.to_rfc2822();
}
}
"<unknown time>".into()
}
/// If [Config::startup_cmd] is non-`None`, call this function to run that command (in a thread, so
/// this is non-blocking).
fn startup_cmd(cmd: String) {
thread::spawn(move || match env::var("SHELL") {
Ok(s) => match Command::new(s).args(["-c", &cmd]).spawn() {
Ok(mut child) => match child.wait() {
Ok(status) => {
if !status.success() {
error!(
"'{cmd:}' returned {}",
status
.code()
.map(|x| x.to_string())
.unwrap_or_else(|| "<Unknown exit code".to_string())
);
}
}
Err(e) => error!("Waiting on '{cmd:}' failed: {e:}"),
},
Err(e) => error!("Couldn't execute '{cmd:}': {e:}"),
},
Err(e) => error!("{e:}"),
});
}
pub fn server(conf_path: PathBuf, conf: Config, cache_path: &Path) -> Result<(), Box<dyn Error>> {
let sock_path = sock_path(cache_path);
#[cfg(target_os = "openbsd")]
unveil(
conf_path
.as_os_str()
.to_str()
.ok_or("Cannot use configuration path in unveil")?,
"rx",
)?;
#[cfg(target_os = "openbsd")]
unveil(
sock_path
.as_os_str()
.to_str()
.ok_or("Cannot use socket path in unveil")?,
"rwxc",
)?;
#[cfg(target_os = "openbsd")]
unveil(std::env::var("SHELL")?, "rx")?;
#[cfg(target_os = "openbsd")]
unveil("/dev/random", "rx")?;
#[cfg(target_os = "openbsd")]
unveil("", "")?;
#[cfg(target_os = "openbsd")]
pledge("stdio rpath wpath inet fattr unix dns proc exec", None).unwrap();
let (http_port, http_state) = match http_server::http_server_setup(&conf)? {
Some((x, y)) => (Some(x), Some(y)),
None => (None, None),
};
let (https_port, https_state, certified_key) = match http_server::https_server_setup(&conf)? {
Some((x, y, z)) => (Some(x), Some(y), Some(z)),
None => (None, None, None),
};
// TODO: Store certificate into trusted folder (OS dependent..)?
let eventer = Arc::new(Eventer::new()?);
let notifier = Arc::new(Notifier::new()?);
let refresher = Refresher::new();
let pub_key_str = certified_key.as_ref().map(|x| {
x.signing_key
.public_key_raw()
.iter()
.map(|x| format!("{x:02X}"))
.collect::<Vec<_>>()
.join(":")
});
let pstate = Arc::new(AuthenticatorState::new(
conf_path,
conf,
http_port,
https_port,
pub_key_str,
Arc::clone(&eventer),
Arc::clone(¬ifier),
Arc::clone(&refresher),
));
if let Some(x) = http_state {
http_server::http_server(Arc::clone(&pstate), x)?;
}
if let (Some(x), Some(y)) = (https_state, certified_key) {
http_server::https_server(Arc::clone(&pstate), x, y)?;
}
eventer.eventer(Arc::clone(&pstate))?;
refresher.refresher(Arc::clone(&pstate))?;
notifier.notifier(Arc::clone(&pstate))?;
let listener = UnixListener::bind(sock_path)?;
if let Some(s) = &pstate.ct_lock().config().startup_cmd {
startup_cmd(s.to_owned());
}
for stream in listener.incoming().flatten() {
let pstate = Arc::clone(&pstate);
if let Err(e) = request(pstate, stream) {
warn!("{e:}");
}
}
Ok(())
}
================================================
FILE: src/server/notifier.rs
================================================
use std::{
cmp,
error::Error,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
use boot_time::Instant;
#[cfg(debug_assertions)]
use log::debug;
use log::error;
use crate::{
server::{AccountId, AuthenticatorState, CTGuard, TokenState, MAX_WAIT_SECS},
shell_cmd::shell_cmd,
};
/// How long to run `auth_notify_cmd`s before killing them?
const AUTH_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to run `error_notify_cmd`s before killing them?
const ERROR_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);
pub struct Notifier {
pred: Mutex<bool>,
condvar: Condvar,
}
impl Notifier {
pub fn new() -> Result<Notifier, Box<dyn Error>> {
Ok(Notifier {
pred: Mutex::new(false),
condvar: Condvar::new(),
})
}
pub fn notifier(
self: Arc<Self>,
pstate: Arc<AuthenticatorState>,
) -> Result<(), Box<dyn Error>> {
thread::spawn(move || loop {
let next_wakeup = self.next_wakeup(&pstate);
let mut notify_lk = self.pred.lock().unwrap();
while !*notify_lk {
match next_wakeup {
Some(t) => match t.checked_duration_since(Instant::now()) {
Some(d) => {
#[cfg(debug_assertions)]
debug!("Notifier: next wakeup {}", d.as_secs());
notify_lk = self.condvar.wait_timeout(notify_lk, d).unwrap().0
}
None => break,
},
None => {
#[cfg(debug_assertions)]
debug!("Notifier: next wakeup <indefinite>");
notify_lk = self.condvar.wait(notify_lk).unwrap();
}
}
}
*notify_lk = false;
drop(notify_lk);
let mut auth_cmds = Vec::new();
let mut ct_lk = pstate.ct_lock();
let now = Instant::now();
let notify_interval = ct_lk.config().auth_notify_interval; // Pulled out to avoid borrow checker problems.
for act_id in ct_lk.act_ids().collect::<Vec<_>>() {
let mut ts = ct_lk.tokenstate(act_id).clone();
if let TokenState::Pending {
ref mut last_notification,
ref url,
..
} = ts
{
if let Some(t) = last_notification {
if let Some(t) = t.checked_add(notify_interval) {
if t > now {
continue;
}
}
}
*last_notification = Some(now);
let url = url.clone();
let act = ct_lk.account(act_id);
if let Some(ref cmd) = ct_lk.config().auth_notify_cmd {
auth_cmds.push((act.name.to_owned(), cmd.clone(), url));
}
ct_lk.tokenstate_replace(act_id, ts);
}
}
drop(ct_lk);
for (act_name, cmd, url) in auth_cmds.into_iter() {
if let Err(e) = shell_cmd(
&cmd,
[
("PIZAUTH_ACCOUNT", act_name.as_str()),
("PIZAUTH_URL", url.as_str()),
],
AUTH_NOTIFY_CMD_TIMEOUT,
) {
error!("{e}")
}
}
});
Ok(())
}
pub fn notify_changes(&self) {
let mut notify_lk = self.pred.lock().unwrap();
*notify_lk = true;
self.condvar.notify_one();
}
fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {
let ct_lk = pstate.ct_lock();
ct_lk
.act_ids()
.filter_map(|act_id| notify_at(pstate, &ct_lk, act_id))
.min()
.map(
|act_min| match Instant::now().checked_add(Duration::from_secs(MAX_WAIT_SECS)) {
Some(x) => cmp::min(act_min, x),
None => act_min,
},
)
}
pub fn notify_error(
&self,
pstate: &AuthenticatorState,
act_name: String,
msg: String,
) -> Result<(), Box<dyn std::error::Error>> {
let cmd = {
let ct_lk = pstate.ct_lock();
ct_lk.config().error_notify_cmd.clone()
};
if let Some(cmd) = cmd {
if let Err(e) = shell_cmd(
&cmd,
[
("PIZAUTH_ACCOUNT", act_name.as_str()),
("PIZAUTH_MSG", &msg),
],
ERROR_NOTIFY_CMD_TIMEOUT,
) {
error!("{e}")
}
}
Ok(())
}
}
/// If `act_id` has a pending token, return the next time when that user should be notified that
/// it is pending.
fn notify_at(_pstate: &AuthenticatorState, ct_lk: &CTGuard, act_id: AccountId) -> Option<Instant> {
match ct_lk.tokenstate(act_id) {
TokenState::Pending {
last_notification, ..
} => {
match last_notification {
None => Some(Instant::now()),
Some(t) => {
// There is no concept of Instant::MAX, so if `refreshed_at + d` exceeds
// Instant's bounds, there's nothing we can fall back on.
t.checked_add(ct_lk.config().auth_notify_interval)
}
}
}
_ => None,
}
}
================================================
FILE: src/server/refresher.rs
================================================
use std::{
cmp,
collections::HashSet,
error::Error,
sync::{Arc, Condvar, Mutex},
thread,
time::Duration,
};
use boot_time::Instant;
#[cfg(debug_assertions)]
use log::debug;
use log::{error, info};
use serde_json::Value;
use crate::{
server::{
eventer::TokenEvent, expiry_instant, AccountId, AuthenticatorState, CTGuard, TokenState,
MAX_WAIT_SECS, UREQ_TIMEOUT,
},
shell_cmd::shell_cmd,
};
/// How many times can a transient error be encountered before we try `not_transient_error_if`?
const TRANSIENT_ERROR_RETRIES: u64 = 6;
/// How long to run `transient_error_if_cmd` commands before killing them?
const TRANSIENT_ERROR_IF_CMD_TIMEOUT: Duration = Duration::from_secs(3 * 60);
/// The outcome of an attempted refresh.
#[derive(Debug)]
enum RefreshKind {
/// Refreshing terminated because the config or tokenstate changed.
AccountOrTokenStateChanged,
/// There is no refresh token so refreshing cannot succeed.
NoRefreshToken,
/// Refreshing failed in a way that is likely to repeat if retried.
PermanentError(String),
/// The token was refreshed.
Refreshed,
/// Refreshing failed but in a way that is not likely to repeat if retried.
TransitoryError(AccountId, String),
}
pub struct Refresher {
pred: Mutex<bool>,
condvar: Condvar,
}
impl Refresher {
pub fn new() -> Arc<Self> {
Arc::new(Refresher {
pred: Mutex::new(false),
condvar: Condvar::new(),
})
}
pub fn sched_refresh(self: &Arc<Self>, pstate: Arc<AuthenticatorState>, act_id: AccountId) {
let refresher = Arc::clone(self);
thread::spawn(move || {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
let mut new_ts = ct_lk.tokenstate(act_id).clone();
if let TokenState::Active {
ref mut ongoing_refresh,
..
} = new_ts
{
if !*ongoing_refresh {
*ongoing_refresh = true;
let act_id = ct_lk.tokenstate_replace(act_id, new_ts);
let act_name = ct_lk.account(act_id).name.clone();
match refresher.inner_refresh(&pstate, ct_lk, act_id) {
RefreshKind::AccountOrTokenStateChanged => (),
RefreshKind::NoRefreshToken => (),
RefreshKind::PermanentError(msg) => {
info!("Permanent refresh error for {act_name}: {msg}");
pstate
.eventer
.token_event(act_name, TokenEvent::Invalidated);
}
RefreshKind::Refreshed => {
refresher.notify_changes();
pstate.eventer.token_event(act_name, TokenEvent::Refresh);
}
RefreshKind::TransitoryError(act_id, msg) => {
ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
let mut new_ts = ct_lk.tokenstate(act_id).clone();
if let TokenState::Active {
ref mut last_refresh_attempt,
ref mut consecutive_refresh_fails,
..
} = new_ts
{
*last_refresh_attempt = Some(Instant::now());
*consecutive_refresh_fails += 1;
let consecutive_refresh_fails = *consecutive_refresh_fails;
let act_id = ct_lk.tokenstate_replace(act_id, new_ts);
if consecutive_refresh_fails
.rem_euclid(TRANSIENT_ERROR_RETRIES)
== 0
{
if let Some(ref cmd) =
ct_lk.config().transient_error_if_cmd
{
let cmd = cmd.to_owned();
drop(ct_lk);
match shell_cmd(
&cmd,
[("PIZAUTH_ACCOUNT", act_name.as_str())],
TRANSIENT_ERROR_IF_CMD_TIMEOUT,
) {
Ok(()) => {
ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
ct_lk.tokenstate_set_ongoing_refresh(
act_id, false,
);
}
drop(ct_lk);
}
Err(e) => {
ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
ct_lk.tokenstate_replace(
act_id,
TokenState::Empty,
);
}
drop(ct_lk);
error!("Permanent refresh error for {act_name}: {e}");
pstate.eventer.token_event(
act_name,
TokenEvent::Invalidated,
);
}
};
} else {
ct_lk.tokenstate_set_ongoing_refresh(act_id, false);
drop(ct_lk);
info!("Transitory refresh error for {act_name}: {msg}");
}
} else {
ct_lk.tokenstate_set_ongoing_refresh(act_id, false);
drop(ct_lk);
info!("Transitory refresh error for {act_name}: {msg}");
}
} else {
unreachable!();
}
} else {
drop(ct_lk);
}
// If the main refresher thread noticed we were running it
// might have given up, so give it a chance to recalculate when
// it should next wake up.
refresher.notify_changes();
}
}
}
}
}
});
}
/// For a [TokenState::Active] token for `act_id`, refresh it, blocking until the token is
/// refreshed or an error occurred. This function must be called with a [TokenState::Active]
/// tokenstate.
///
/// # Panics
///
/// If the tokenstate is not [TokenState::Active].
fn inner_refresh(
&self,
pstate: &AuthenticatorState,
mut ct_lk: CTGuard,
mut act_id: AccountId,
) -> RefreshKind {
info!("starting inner refresh");
let mut new_ts = ct_lk.tokenstate(act_id).clone();
let refresh_token = match new_ts {
TokenState::Active {
ref refresh_token,
ref mut last_refresh_attempt,
..
} => match refresh_token {
Some(r) => {
*last_refresh_attempt = Some(Instant::now());
let r = r.to_owned();
act_id = ct_lk.tokenstate_replace(act_id, new_ts);
r
}
None => {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
return RefreshKind::NoRefreshToken;
}
},
_ => unreachable!("tokenstate is not TokenState::Active"),
};
let act = ct_lk.account(act_id);
let token_uri = act.token_uri.clone();
let client_id = act.client_id.clone();
let mut pairs = vec![
("client_id", client_id.as_str()),
("refresh_token", refresh_token.as_str()),
("grant_type", "refresh_token"),
];
let client_secret = act.client_secret.clone();
if let Some(ref x) = client_secret {
pairs.push(("client_secret", x));
}
drop(ct_lk);
let agent_conf = ureq::Agent::config_builder()
.timeout_global(Some(UREQ_TIMEOUT))
.build();
let body = match ureq::Agent::new_with_config(agent_conf)
.post(token_uri.as_str())
.send_form(pairs)
{
Ok(response) => match response.into_body().read_to_string() {
Ok(s) => s,
Err(e) => return RefreshKind::TransitoryError(act_id, e.to_string()),
},
Err(ureq::Error::StatusCode(code)) => {
let reason = format!("HTTP code {code}");
if let 408 | 429 | 500 | 502 | 503 | 504 = code {
return RefreshKind::TransitoryError(act_id, reason);
} else {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
return RefreshKind::PermanentError(reason);
} else {
return RefreshKind::AccountOrTokenStateChanged;
}
}
}
Err(
e @ ureq::Error::ConnectionFailed
| e @ ureq::Error::HostNotFound
| e @ ureq::Error::Io(_)
| e @ ureq::Error::Timeout(_),
) => return RefreshKind::TransitoryError(act_id, e.to_string()),
Err(e) => {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
return RefreshKind::PermanentError(e.to_string());
} else {
return RefreshKind::AccountOrTokenStateChanged;
}
}
};
let parsed = match serde_json::from_str::<Value>(&body) {
Ok(v) => {
if let Some(e) = v["error"].as_str() {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);
let msg =
format!("Refreshing {} failed: {}", ct_lk.account(act_id).name, e);
return RefreshKind::PermanentError(msg);
} else {
return RefreshKind::AccountOrTokenStateChanged;
}
}
v
}
Err(e) => {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
let act_id = ct_lk.tokenstate_replace(act_id, TokenState::Empty);
let msg = format!("Refreshing {} failed: {e}", ct_lk.account(act_id).name);
return RefreshKind::PermanentError(msg);
} else {
return RefreshKind::AccountOrTokenStateChanged;
}
}
};
match (
parsed["access_token"].as_str(),
parsed["expires_in"].as_u64(),
parsed["token_type"].as_str(),
) {
(Some(access_token), Some(expires_in), Some("Bearer")) => {
let refresh_token = match parsed.get("refresh_token") {
None => Some(refresh_token),
Some(Value::String(x)) => Some(x.to_owned()),
Some(_) => None,
};
let now = Instant::now();
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
let expiry = match expiry_instant(&ct_lk, act_id, now, expires_in) {
Ok(x) => x,
Err(e) => {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
return RefreshKind::PermanentError(format!("{e}"));
}
};
ct_lk.tokenstate_replace(
act_id,
TokenState::Active {
access_token: access_token.to_owned(),
access_token_obtained: now,
access_token_expiry: expiry,
ongoing_refresh: false,
consecutive_refresh_fails: 0,
last_refresh_attempt: None,
refresh_token,
},
);
drop(ct_lk);
RefreshKind::Refreshed
} else {
RefreshKind::AccountOrTokenStateChanged
}
}
_ => {
let mut ct_lk = pstate.ct_lock();
if ct_lk.is_act_id_valid(act_id) {
ct_lk.tokenstate_replace(act_id, TokenState::Empty);
RefreshKind::PermanentError("Received JSON in unexpected format".to_string())
} else {
RefreshKind::AccountOrTokenStateChanged
}
}
}
}
/// If `act_id` has an active token, return the time when that token should be refreshed.
fn refresh_at(
&self,
_pstate: &AuthenticatorState,
ct_lk: &CTGuard,
act_id: AccountId,
) -> Option<Instant> {
match ct_lk.tokenstate(act_id) {
TokenState::Active {
access_token_obtained,
access_token_expiry,
ongoing_refresh,
last_refresh_attempt,
..
} if !ongoing_refresh => {
let act = &ct_lk.account(act_id);
if let Some(lra) = last_refresh_attempt {
// There are two ways for `last_refresh_attempt` to be non-`None`:
// 1. The token expired (i.e. last_refresh_attempt > expiry).
// 2. The user tried manually refreshing the token but that refreshing has
// not yet succeeded (and it is possible that last_refresh_attempt <
// expiry).
// If the second case occurs, we assume that the user knows that the token
// really needs refreshing, and we treat the token as if it had expired.
if let Some(t) = lra.checked_add(act.refresh_retry(ct_lk.config())) {
return Some(t.to_owned());
}
}
let mut expiry = access_token_expiry
.checked_sub(act.refresh_before_expiry(ct_lk.config()))
.unwrap_or_else(|| cmp::min(Instant::now(), *access_token_expiry));
// There is no concept of Instant::MAX, so if `access_token_obtained + d` exceeds
// Instant's bounds, there's nothing we can fall back on.
if let Some(t) =
access_token_obtained.checked_add(act.refresh_at_least(ct_lk.config()))
{
expiry = cmp::min(expiry, t);
}
Some(expiry.to_owned())
}
_ => None,
}
}
fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {
let ct_lk = pstate.ct_lock();
ct_lk
.act_ids()
.filter_map(|act_id| self.refresh_at(pstate, &ct_lk, act_id))
.min()
.map(
|act_min| match Instant::now().checked_add(Duration::from_secs(MAX_WAIT_SECS)) {
Some(x) => cmp::min(act_min, x),
None => act_min,
},
)
}
/// Notify the refresher that one or more [TokenState]s is likely to have changed in a way that
/// effects the refresher.
pub fn notify_changes(&self) {
let mut refresh_lk = self.pred.lock().unwrap();
*refresh_lk = true;
self.condvar.notify_one();
}
/// Start the refresher thread.
pub fn refresher(
self: Arc<Self>,
pstate: Arc<AuthenticatorState>,
) -> Result<(), Box<dyn Error>> {
let refresher = Arc::clone(&self);
thread::spawn(move || loop {
let next_wakeup = refresher.next_wakeup(&pstate);
let mut refresh_lk = refresher.pred.lock().unwrap();
while !*refresh_lk {
match next_wakeup {
Some(t) => match t.checked_duration_since(Instant::now()) {
Some(d) => {
#[cfg(debug_assertions)]
debug!("Refresher: next wakeup {}", d.as_secs());
refresh_lk = refresher.condvar.wait_timeout(refresh_lk, d).unwrap().0
}
None => break,
},
None => {
#[cfg(debug_assertions)]
debug!("Refresher: next wakeup <indefinite>");
refresh_lk = refresher.condvar.wait(refresh_lk).unwrap();
}
}
}
*refresh_lk = false;
drop(refresh_lk);
let ct_lk = pstate.ct_lock();
let now = Instant::now();
let to_refresh = ct_lk
.act_ids()
.filter(
|act_id| match refresher.refresh_at(&pstate, &ct_lk, *act_id) {
Some(t) => t <= now,
None => false,
},
)
.collect::<HashSet<_>>();
drop(ct_lk);
for act_id in to_refresh.iter() {
refresher.sched_refresh(Arc::clone(&pstate), *act_id);
}
});
Ok(())
}
}
================================================
FILE: src/server/request_token.rs
================================================
use std::{error::Error, sync::Arc};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::{rng, Rng};
use sha2::{Digest, Sha256};
use url::Url;
use super::{AccountId, AuthenticatorState, CTGuard, TokenState, CODE_VERIFIER_LEN, STATE_LEN};
/// Request a new token for `act_id`, whose tokenstate must be `Empty`.
pub fn request_token(
pstate: Arc<AuthenticatorState>,
mut ct_lk: CTGuard,
act_id: AccountId,
) -> Result<Url, Box<dyn Error>> {
assert!(matches!(
ct_lk.tokenstate(act_id),
TokenState::Empty | TokenState::Pending { .. }
));
let act = ct_lk.account(act_id);
let mut state = [0u8; STATE_LEN];
rng().fill_bytes(&mut state);
let state = URL_SAFE_NO_PAD.encode(state);
let mut code_verifier = [0u8; CODE_VERIFIER_LEN];
rng().fill_bytes(&mut code_verifier);
let code_verifier = URL_SAFE_NO_PAD.encode(code_verifier);
let mut hasher = Sha256::new();
hasher.update(&code_verifier);
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
let scopes_join = act.scopes.join(" ");
let redirect_uri = act
.redirect_uri(pstate.http_port, pstate.https_port)?
.to_string();
let mut params = vec![
("access_type", "offline"),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
("client_id", act.client_id.as_str()),
("redirect_uri", redirect_uri.as_str()),
("response_type", "code"),
("state", &state),
];
if !act.scopes.is_empty() {
params.push(("scope", scopes_join.as_str()));
}
for (k, v) in &act.auth_uri_fields {
params.push((k.as_str(), v.as_str()));
}
let url = Url::parse_with_params(ct_lk.account(act_id).auth_uri.as_str(), ¶ms)?;
ct_lk.tokenstate_replace(
act_id,
TokenState::Pending {
code_verifier,
last_notification: None,
url: url.clone(),
state,
},
);
drop(ct_lk);
pstate.notifier.notify_changes();
Ok(url)
}
================================================
FILE: src/server/state.rs
================================================
//! This module contains pizauth's core central state. [AuthenticatorState] is the global state,
//! but mostly what one is interested in are [Account]s and [TokenState]s. These are (literally)
//! locked together: every [Account] has a [TokenState] and vice versa. However, a challenge is
//! that we allow users to reload their config at any point: we have to be very careful about
//! associating an [Account] with a [TokenState], as we don't want to hand out credentials for an
//! old version of an account.
//!
//! To that end, we provide an abstraction [AccountId] which is a sort-of "the current version of
//! an [Account]". Any change to the user's configuration of an [Account] *or* a change to an
//! [Account]'s associated [TokenState] will cause the [AccountId] to change. Every time a
//! [CTGuard] is dropped/reacquired, or [tokenstate_replace] is called, [AccountId]s must be
//! revalidated. Failing to do so will cause panics.
use std::{
collections::HashMap,
error::Error,
path::PathBuf,
sync::{Arc, Mutex, MutexGuard},
time::SystemTime,
};
use boot_time::Instant;
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use rand::{rng, RngExt};
use serde::{Deserialize, Serialize};
use url::Url;
use wincode::{deserialize, serialize, SchemaRead, SchemaWrite};
use super::{eventer::Eventer, notifier::Notifier, refresher::Refresher};
use crate::config::{Account, AccountDump, Config};
/// We lightly encrypt the dump output to make it at least resistant to simple string-based
/// grepping. This is the length of the dump nonce.
const NONCE_LEN: usize = 12;
/// The ChaCha20 key for the dump.
const CHACHA20_KEY: &[u8; 32] = b"\x66\xa2\x47\xa8\x5e\x48\xcf\xec\xaa\xed\x9b\x36\xeb\xa9\x7d\x53\x50\xd4\x28\x63\x75\x09\x7a\x44\xee\xff\xb9\xc4\x54\x6b\x65\xa3";
/// The format of the dump. Monotonically increment if the semantics of the `pizauth dump` change
/// in an incompatible manner.
const DUMP_VERSION: u64 = 1;
/// pizauth's global state.
pub struct AuthenticatorState {
pub conf_path: PathBuf,
/// The "global lock" protecting the config and current [TokenState]s. Can only be accessed via
/// [AuthenticatorState::ct_lock].
locked_state: Mutex<LockedState>,
/// Port of the HTTP server required by OAuth.
pub http_port: Option<u16>,
/// Port of the HTTPS server required by OAuth.
pub https_port: Option<u16>,
/// If an HTTPS server is running, its raw public key formatted in hex with each byte separated by `:`.
pub https_pub_key: Option<String>,
pub eventer: Arc<Eventer>,
pub notifier: Arc<Notifier>,
pub refresher: Arc<Refresher>,
}
impl AuthenticatorState {
pub fn new(
conf_
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
SYMBOL INDEX (179 symbols across 15 files)
FILE: build.rs
function main (line 5) | fn main() -> Result<(), Box<dyn std::error::Error>> {
FILE: src/compat/daemon.rs
function daemon (line 25) | pub fn daemon(nochdir: bool, noclose: bool) -> nix::Result<()> {
FILE: src/config.rs
type StorageT (line 16) | type StorageT = u8;
constant REFRESH_BEFORE_EXPIRY_DEFAULT (line 19) | const REFRESH_BEFORE_EXPIRY_DEFAULT: Duration = Duration::from_secs(90);
constant REFRESH_AT_LEAST_DEFAULT (line 22) | const REFRESH_AT_LEAST_DEFAULT: Duration = Duration::from_secs(90 * 60);
constant REFRESH_RETRY_DEFAULT (line 24) | const REFRESH_RETRY_DEFAULT: Duration = Duration::from_secs(40);
constant AUTH_NOTIFY_INTERVAL_DEFAULT (line 27) | const AUTH_NOTIFY_INTERVAL_DEFAULT: u64 = 15 * 60;
constant HTTP_LISTEN_DEFAULT (line 29) | const HTTP_LISTEN_DEFAULT: &str = "127.0.0.1:0";
constant HTTPS_LISTEN_DEFAULT (line 31) | const HTTPS_LISTEN_DEFAULT: &str = "127.0.0.1:0";
type Config (line 34) | pub struct Config {
method from_path (line 52) | pub fn from_path(conf_path: &Path) -> Result<Self, String> {
method from_str (line 60) | pub fn from_str(input: &str) -> Result<Self, String> {
function check_not_assigned (line 255) | fn check_not_assigned<T>(
function check_not_assigned_str (line 271) | fn check_not_assigned_str<T>(
function check_not_assigned_time (line 287) | fn check_not_assigned_time<'a, T>(
function check_not_assigned_uri (line 303) | fn check_not_assigned_uri<T>(
function check_assigned (line 337) | fn check_assigned<T>(
type Account (line 363) | pub struct Account {
method from_fields (line 378) | fn from_fields(
method secure_eq (line 524) | pub fn secure_eq(&self, other: &Self) -> bool {
method dump (line 539) | pub fn dump(&self) -> AccountDump {
method secure_restorable (line 555) | pub fn secure_restorable(&self, act_dump: &AccountDump) -> bool {
method redirect_uri (line 565) | pub fn redirect_uri(
method refresh_at_least (line 582) | pub fn refresh_at_least(&self, config: &Config) -> Duration {
method refresh_before_expiry (line 588) | pub fn refresh_before_expiry(&self, config: &Config) -> Duration {
method refresh_retry (line 594) | pub fn refresh_retry(&self, config: &Config) -> Duration {
type AccountDump (line 602) | pub struct AccountDump {
function time_str_to_duration (line 617) | fn time_str_to_duration(t: &str) -> Result<Duration, String> {
function unescape_str (line 640) | fn unescape_str(us: &str) -> String {
function error_at_span (line 667) | fn error_at_span(
function test_unescape_string (line 694) | fn test_unescape_string() {
function test_time_str_to_duration (line 703) | fn test_time_str_to_duration() {
function string_escapes (line 724) | fn string_escapes() {
function valid_config (line 735) | fn valid_config() {
function at_least_one_account (line 790) | fn at_least_one_account() {
function invalid_time (line 798) | fn invalid_time() {
function dup_fields (line 806) | fn dup_fields() {
function one_of_http_or_https (line 876) | fn one_of_http_or_https() {
function http_or_https_redirect_uris_only (line 895) | fn http_or_https_redirect_uris_only() {
function correct_listen_for_account (line 928) | fn correct_listen_for_account() {
function invalid_uris (line 973) | fn invalid_uris() {
function valid_https_config (line 989) | fn valid_https_config() {
function mandatory_account_fields (line 1016) | fn mandatory_account_fields() {
function local_overrides (line 1044) | fn local_overrides() {
function login_hint_mutually_exclusive_query_field (line 1204) | fn login_hint_mutually_exclusive_query_field() {
function endpoints_no_fragment (line 1220) | fn endpoints_no_fragment() {
FILE: src/config_ast.rs
type TopLevel (line 3) | pub enum TopLevel {
type AccountField (line 21) | pub enum AccountField {
FILE: src/main.rs
constant PIZAUTH_CACHE_LEAF (line 42) | const PIZAUTH_CACHE_LEAF: &str = "pizauth";
constant PIZAUTH_CACHE_SOCK_LEAF (line 44) | const PIZAUTH_CACHE_SOCK_LEAF: &str = "pizauth.sock";
constant PIZAUTH_CONF_LEAF (line 46) | const PIZAUTH_CONF_LEAF: &str = "pizauth.conf";
function progname (line 48) | fn progname() -> String {
function fatal (line 60) | fn fatal(msg: &str) -> ! {
function usage (line 66) | fn usage() -> ! {
function cache_path (line 74) | fn cache_path() -> PathBuf {
function conf_path (line 110) | fn conf_path(matches: &getopts::Matches) -> PathBuf {
function main (line 137) | fn main() {
FILE: src/server/eventer.rs
constant TOKEN_EVENT_CMD_TIMEOUT (line 15) | const TOKEN_EVENT_CMD_TIMEOUT: Duration = Duration::from_secs(10);
type TokenEvent (line 17) | pub enum TokenEvent {
method fmt (line 25) | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
type Eventer (line 35) | pub struct Eventer {
method new (line 42) | pub fn new() -> Result<Self, Box<dyn Error>> {
method eventer (line 50) | pub fn eventer(self: Arc<Self>, pstate: Arc<AuthenticatorState>) -> Re...
method token_event (line 89) | pub fn token_event(&self, act_name: String, kind: TokenEvent) {
FILE: src/server/http_server.rs
constant RETRY_POST (line 28) | const RETRY_POST: u8 = 10;
constant RETRY_DELAY (line 30) | const RETRY_DELAY: u64 = 6;
constant MAX_HTTP_REQUEST_SIZE (line 35) | const MAX_HTTP_REQUEST_SIZE: usize = 16 * 1024;
function request (line 38) | fn request<T: Read + Write>(
function fail (line 242) | fn fail(
function parse_get (line 273) | fn parse_get<T: Read + Write>(stream: &mut T, is_https: bool) -> Result<...
function http_200 (line 349) | fn http_200<T: Read + Write>(mut stream: T, body: &str) {
function http_404 (line 357) | fn http_404<T: Read + Write>(mut stream: T) {
function http_400 (line 361) | fn http_400<T: Read + Write>(mut stream: T) {
function redirect_uri_matches (line 366) | fn redirect_uri_matches(expected: &Url, actual: &Url) -> bool {
function http_server_setup (line 387) | pub fn http_server_setup(conf: &Config) -> Result<Option<(u16, TcpListen...
function http_server (line 398) | pub fn http_server(
function https_server_setup (line 415) | pub fn https_server_setup(
function https_server (line 444) | pub fn https_server(
function redirect_uri_matching (line 489) | fn redirect_uri_matching() {
FILE: src/server/mod.rs
constant CODE_VERIFIER_LEN (line 39) | const CODE_VERIFIER_LEN: usize = 64;
constant UREQ_TIMEOUT (line 42) | pub const UREQ_TIMEOUT: Duration = Duration::from_secs(30);
constant STATE_LEN (line 47) | const STATE_LEN: usize = 32;
constant MAX_WAIT_SECS (line 61) | const MAX_WAIT_SECS: u64 = 37;
function sock_path (line 63) | pub fn sock_path(cache_path: &Path) -> PathBuf {
function expiry_instant (line 71) | pub fn expiry_instant(
function request (line 85) | fn request(pstate: Arc<AuthenticatorState>, mut stream: UnixStream) -> R...
function instant_fmt (line 312) | fn instant_fmt(i: Instant) -> String {
function startup_cmd (line 332) | fn startup_cmd(cmd: String) {
function server (line 355) | pub fn server(conf_path: PathBuf, conf: Config, cache_path: &Path) -> Re...
FILE: src/server/notifier.rs
constant AUTH_NOTIFY_CMD_TIMEOUT (line 20) | const AUTH_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);
constant ERROR_NOTIFY_CMD_TIMEOUT (line 22) | const ERROR_NOTIFY_CMD_TIMEOUT: Duration = Duration::from_secs(10);
type Notifier (line 24) | pub struct Notifier {
method new (line 30) | pub fn new() -> Result<Notifier, Box<dyn Error>> {
method notifier (line 37) | pub fn notifier(
method notify_changes (line 111) | pub fn notify_changes(&self) {
method next_wakeup (line 117) | fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {
method notify_error (line 131) | pub fn notify_error(
function notify_at (line 159) | fn notify_at(_pstate: &AuthenticatorState, ct_lk: &CTGuard, act_id: Acco...
FILE: src/server/refresher.rs
constant TRANSIENT_ERROR_RETRIES (line 25) | const TRANSIENT_ERROR_RETRIES: u64 = 6;
constant TRANSIENT_ERROR_IF_CMD_TIMEOUT (line 27) | const TRANSIENT_ERROR_IF_CMD_TIMEOUT: Duration = Duration::from_secs(3 *...
type RefreshKind (line 31) | enum RefreshKind {
type Refresher (line 44) | pub struct Refresher {
method new (line 50) | pub fn new() -> Arc<Self> {
method sched_refresh (line 57) | pub fn sched_refresh(self: &Arc<Self>, pstate: Arc<AuthenticatorState>...
method inner_refresh (line 173) | fn inner_refresh(
method refresh_at (line 336) | fn refresh_at(
method next_wakeup (line 381) | fn next_wakeup(&self, pstate: &AuthenticatorState) -> Option<Instant> {
method notify_changes (line 397) | pub fn notify_changes(&self) {
method refresher (line 404) | pub fn refresher(
FILE: src/server/request_token.rs
function request_token (line 11) | pub fn request_token(
FILE: src/server/state.rs
constant NONCE_LEN (line 37) | const NONCE_LEN: usize = 12;
constant CHACHA20_KEY (line 39) | const CHACHA20_KEY: &[u8; 32] = b"\x66\xa2\x47\xa8\x5e\x48\xcf\xec\xaa\x...
constant DUMP_VERSION (line 42) | const DUMP_VERSION: u64 = 1;
type AuthenticatorState (line 45) | pub struct AuthenticatorState {
method new (line 62) | pub fn new(
method ct_lock (line 91) | pub fn ct_lock(&self) -> CTGuard<'_> {
method update_conf (line 98) | pub fn update_conf(&self, new_conf: Config) {
method dump (line 107) | pub fn dump(&self) -> Result<Vec<u8>, Box<dyn Error>> {
method restore (line 128) | pub fn restore(&self, d: Vec<u8>) -> Result<(), Box<dyn Error>> {
type LockedState (line 154) | struct LockedState {
method new (line 168) | fn new(config: Config) -> Self {
method update_conf (line 187) | fn update_conf(&mut self, config: Config) {
method dump (line 229) | fn dump(&self) -> Result<Vec<u8>, Box<dyn Error>> {
method restore (line 244) | fn restore(&mut self, dump: Vec<u8>) -> Result<(), Box<dyn Error>> {
method next_account_id (line 287) | fn next_account_id(&mut self) -> AccountId {
type Dump (line 297) | struct Dump {
type CTGuard (line 308) | pub struct CTGuard<'a> {
function new (line 313) | fn new(guard: MutexGuard<'a, LockedState>) -> CTGuard<'a> {
function config (line 317) | pub fn config(&self) -> &Config {
function validate_act_name (line 322) | pub fn validate_act_name(&self, act_name: &str) -> Option<AccountId> {
function is_act_id_valid (line 331) | pub fn is_act_id_valid(&self, act_id: AccountId) -> bool {
function act_ids (line 336) | pub fn act_ids(&self) -> impl Iterator<Item = AccountId> + '_ {
function act_id_matching_token_state (line 341) | pub fn act_id_matching_token_state(&self, state: &str) -> Option<Account...
function account (line 354) | pub fn account(&self, act_id: AccountId) -> &Account {
function tokenstate (line 371) | pub fn tokenstate(&self, act_id: AccountId) -> &TokenState {
function tokenstate_set_ongoing_refresh (line 386) | pub fn tokenstate_set_ongoing_refresh(
function tokenstate_replace (line 418) | pub fn tokenstate_replace(
type AccountId (line 438) | pub struct AccountId {
type TokenState (line 443) | pub enum TokenState {
method dump (line 491) | pub fn dump(&self) -> TokenStateDump {
method restore (line 526) | pub fn restore(tsd: &TokenStateDump) -> TokenState {
type TokenStateDump (line 480) | pub enum TokenStateDump {
function test_act_validation (line 568) | fn test_act_validation() {
function dump_restore (line 722) | fn dump_restore() {
function dump_restore_error (line 858) | fn dump_restore_error() {
FILE: src/shell_cmd.rs
function shell_cmd (line 8) | pub fn shell_cmd<const T: usize>(
FILE: src/user_sender.rs
function dump (line 11) | pub fn dump(cache_path: &Path) -> Result<Vec<u8>, Box<dyn Error>> {
function server_info (line 25) | pub fn server_info(cache_path: &Path) -> Result<serde_json::Value, Box<d...
function refresh (line 39) | pub fn refresh(cache_path: &Path, account: &str, with_url: bool) -> Resu...
function reload (line 61) | pub fn reload(cache_path: &Path) -> Result<(), Box<dyn Error>> {
function restore (line 79) | pub fn restore(cache_path: &Path) -> Result<(), Box<dyn Error>> {
function revoke (line 100) | pub fn revoke(cache_path: &Path, account: &str) -> Result<(), Box<dyn Er...
function show_token (line 118) | pub fn show_token(cache_path: &Path, account: &str, with_url: bool) -> R...
function shutdown (line 143) | pub fn shutdown(cache_path: &Path) -> Result<(), Box<dyn Error>> {
function status (line 153) | pub fn status(cache_path: &Path) -> Result<(), Box<dyn Error>> {
FILE: tests/basic.rs
constant ACCOUNT (line 17) | const ACCOUNT: &str = "test_account";
constant CLIENT_ID (line 18) | const CLIENT_ID: &str = "test_client_id";
constant CLIENT_SECRET (line 19) | const CLIENT_SECRET: &str = "test_secret";
constant CODE (line 20) | const CODE: &str = "test_code";
constant ACCESS_TOKEN (line 21) | const ACCESS_TOKEN: &str = "test_access_token";
constant REFRESH_TOKEN (line 22) | const REFRESH_TOKEN: &str = "test_refresh_token";
type PizauthServer (line 24) | struct PizauthServer {
method start (line 30) | fn start(cwd: &Path, xdg_dir: &Path, configp: &Path, readyp: &Path) ->...
method drop (line 50) | fn drop(&mut self) {
type OAuthServer (line 59) | struct OAuthServer {
method start (line 65) | fn start() -> Self {
method auth_uri (line 86) | fn auth_uri(&self) -> String {
method token_uri (line 90) | fn token_uri(&self) -> String {
method join (line 94) | fn join(&mut self) {
function pizauth_config (line 101) | fn pizauth_config(oauths: &OAuthServer) -> String {
function pending_auth_url (line 120) | fn pending_auth_url(output: &Output) -> Url {
function pizauth_cmd (line 127) | fn pizauth_cmd<I, S>(xdg_dir: &Path, args: I) -> Command
function handle_oauth_request (line 139) | fn handle_oauth_request(stream: TcpStream, expected_redirect_uri: &Mutex...
type HttpRequest (line 212) | struct HttpRequest {
method read (line 220) | fn read(stream: TcpStream) -> Self {
method respond (line 257) | fn respond(mut self, status: u16, headers: &[(&str, &str)], body: &str) {
type HttpResponse (line 266) | struct HttpResponse {
function http_get (line 271) | fn http_get(url: &Url) -> HttpResponse {
function basic_request_token (line 312) | fn basic_request_token() {
Condensed preview — 41 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (256K chars).
[
{
"path": ".github/workflows/ci.yml",
"chars": 1072,
"preview": "name: CI\n\non:\n pull_request:\n\njobs:\n rustfmt:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v3\n "
},
{
"path": ".gitignore",
"chars": 8,
"preview": "target/\n"
},
{
"path": "CHANGES.md",
"chars": 8399,
"preview": "# pizauth 1.1.0 (2026-XX-XX)\n\n* `auth_notify_cmd` is now subject to a 10 second timeout. If you previously\n relied on t"
},
{
"path": "COPYRIGHT",
"chars": 558,
"preview": "Except as otherwise noted (below and/or in individual files), this project is\nlicensed under the Apache License, Version"
},
{
"path": "Cargo.toml",
"chars": 1457,
"preview": "[package]\nname = \"pizauth\"\ndescription = \"Command-line OAuth2 authentication daemon\"\nversion = \"1.0.11\"\nrepository = \"ht"
},
{
"path": "LICENSE-APACHE",
"chars": 526,
"preview": "Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use\nthis file except in compliance with the "
},
{
"path": "LICENSE-MIT",
"chars": 1024,
"preview": "Permission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentati"
},
{
"path": "Makefile",
"chars": 2833,
"preview": "PREFIX ?= /usr/local\nBINDIR ?= ${PREFIX}/bin\nLIBDIR ?= ${PREFIX}/lib\nSHAREDIR ?= ${PREFIX}/share\nEXAMPLESDIR ?= ${SHARED"
},
{
"path": "README.md",
"chars": 13147,
"preview": "# pizauth: an OAuth2 token requester daemon\n\npizauth is a simple program for requesting, showing, and refreshing OAuth2\n"
},
{
"path": "README.systemd.md",
"chars": 2871,
"preview": "# Systemd unit\n\nPizauth comes with a systemd unit. In order for it to communicate properly with\n`systemd`, your `startup"
},
{
"path": "build.rs",
"chars": 662,
"preview": "use cfgrammar::yacc::YaccKind;\nuse lrlex::{CTLexerBuilder, DefaultLexerTypes};\nuse rerun_except::rerun_except;\n\nfn main("
},
{
"path": "examples/pizauth-state-custom.service",
"chars": 1770,
"preview": "# In case the supplied pizauth-state-*.service files don't suit your needs,\n# this is the template for a new pizauth-sta"
},
{
"path": "examples/pizauth.conf",
"chars": 617,
"preview": "account \"officesmtp\" {\n auth_uri = \"https://login.microsoftonline.com/common/oauth2/v2.0/authorize\";\n token_uri = "
},
{
"path": "examples/systemd/pizauth.conf",
"chars": 413,
"preview": "// If using systemd, comment out the following line\n// startup_cmd=\"systemd-notify --ready --pid=parent\";\n// If using th"
},
{
"path": "lib/systemd/user/pizauth-state-age.service",
"chars": 947,
"preview": "[Unit]\nDescription=pizauth dump/restore backend (encryption: age)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPr"
},
{
"path": "lib/systemd/user/pizauth-state-creds.service",
"chars": 539,
"preview": "[Unit]\nDescription=pizauth dump/restore backend (encryption: systemd-creds)\nBindsTo=pizauth.service\nAfter=pizauth.servic"
},
{
"path": "lib/systemd/user/pizauth-state-gpg-passphrase.service",
"chars": 1837,
"preview": "[Unit]\nDescription=pizauth dump/restore backend (encryption: gpg, passphrase in systemd-creds)\nBindsTo=pizauth.service\nA"
},
{
"path": "lib/systemd/user/pizauth-state-gpg.service",
"chars": 852,
"preview": "[Unit]\nDescription=pizauth dump/restore backend (encryption: gpg)\nBindsTo=pizauth.service\nAfter=pizauth.service\nReloadPr"
},
{
"path": "lib/systemd/user/pizauth.service",
"chars": 571,
"preview": "[Unit]\nDescription=Pizauth OAuth2 token manager\nDocumentation=man:pizauth(1) man:pizauth.conf(5)\nDocumentation=https://g"
},
{
"path": "pizauth.1",
"chars": 4585,
"preview": ".Dd $Mdocdate: September 13 2022 $\n.Dt PIZAUTH 1\n.Os\n.Sh NAME\n.Nm pizauth\n.Nd OAuth2 authentication daemon\n.Sh SYNOPSIS\n"
},
{
"path": "pizauth.conf.5",
"chars": 7396,
"preview": ".Dd $Mdocdate: September 13 2022 $\n.Dt PIZAUTH.CONF 5\n.Os\n.Sh NAME\n.Nm pizauth.conf\n.Nd pizauth configuration file\n.Sh D"
},
{
"path": "share/bash/completion.bash",
"chars": 2286,
"preview": "#!/bin/bash\n_server() {\n local cur prev\n\n prev=${COMP_WORDS[COMP_CWORD - 1]}\n cur=${COMP_WORDS[COMP_CWORD]}\n "
},
{
"path": "share/fish/pizauth.fish",
"chars": 2674,
"preview": "#!/usr/bin/fish\n\nfunction __fish_pizauth_accounts --description \"Helper function to parse accounts from config\"\n set "
},
{
"path": "share/zsh/_pizauth",
"chars": 1740,
"preview": "#compdef pizauth\n\n_pizauth_accounts() {\n local config account\n local -a accounts\n accounts=(\"${(@f)$(pizauth status 2"
},
{
"path": "src/compat/daemon.rs",
"chars": 1340,
"preview": "//! Provides daemon(3) on macOS.\n\n// We provide our own wrapper for daemon on macOS because nix does not export one for "
},
{
"path": "src/compat/mod.rs",
"chars": 356,
"preview": "//! Shims to provide compatibility with different systems.\n\n// nix does not support daemon(3) on macOS, so we have to pr"
},
{
"path": "src/config.l",
"chars": 788,
"preview": "%%\n[0-9]+[dhms] \"TIME\"\n\"(?:\\\\[\\\\\"]|[^\"\\\\])*\" \"STRING\"\n= \"=\"\n, \",\"\n\\{ \"{\"\n\\} \"}\"\n\\[ \"[\"\n\\] \"]\"\n; \";\"\n: \":\"\naccount \"ACCOU"
},
{
"path": "src/config.rs",
"chars": 46679,
"preview": "use std::{\n collections::HashMap, error::Error, fs::read_to_string, path::Path, sync::Arc, time::Duration,\n};\n\nuse lr"
},
{
"path": "src/config.y",
"chars": 3708,
"preview": "%start TopLevels\n%avoid_insert \"STRING\"\n%epp TIME \"<time>[dhms]\"\n%expect-unused Unmatched \"UNMATCHED\"\n\n%%\n\nTopLevels -> "
},
{
"path": "src/config_ast.rs",
"chars": 754,
"preview": "use lrpar::Span;\n\npub enum TopLevel {\n Account(Span, Span, Vec<AccountField>),\n AuthErrorCmd(Span),\n AuthNotify"
},
{
"path": "src/main.rs",
"chars": 14656,
"preview": "#![allow(clippy::derive_partial_eq_without_eq)]\n#![allow(clippy::too_many_arguments)]\n#![allow(clippy::type_complexity)]"
},
{
"path": "src/server/eventer.rs",
"chars": 2795,
"preview": "use std::{\n collections::VecDeque,\n error::Error,\n fmt::{self, Display, Formatter},\n sync::{Arc, Condvar, Mu"
},
{
"path": "src/server/http_server.rs",
"chars": 17927,
"preview": "use std::{\n error::Error,\n io::{BufRead, BufReader, Read, Write},\n net::TcpListener,\n sync::Arc,\n thread,"
},
{
"path": "src/server/mod.rs",
"chars": 16875,
"preview": "mod eventer;\nmod http_server;\nmod notifier;\nmod refresher;\nmod request_token;\nmod state;\n\nuse std::{\n collections::Ha"
},
{
"path": "src/server/notifier.rs",
"chars": 5761,
"preview": "use std::{\n cmp,\n error::Error,\n sync::{Arc, Condvar, Mutex},\n thread,\n time::Duration,\n};\n\nuse boot_time"
},
{
"path": "src/server/refresher.rs",
"chars": 19935,
"preview": "use std::{\n cmp,\n collections::HashSet,\n error::Error,\n sync::{Arc, Condvar, Mutex},\n thread,\n time::D"
},
{
"path": "src/server/request_token.rs",
"chars": 2072,
"preview": "use std::{error::Error, sync::Arc};\n\nuse base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};\nuse rand::{rng, Rng"
},
{
"path": "src/server/state.rs",
"chars": 33557,
"preview": "//! This module contains pizauth's core central state. [AuthenticatorState] is the global state,\n//! but mostly what one"
},
{
"path": "src/shell_cmd.rs",
"chars": 1617,
"preview": "use std::{env, error::Error, process::Command, time::Duration};\n\nuse wait_timeout::ChildExt;\n\n/// Run the string `cmd` a"
},
{
"path": "src/user_sender.rs",
"chars": 6193,
"preview": "use std::{\n error::Error,\n io::{stdin, Read, Write},\n net::Shutdown,\n os::unix::net::UnixStream,\n path::P"
},
{
"path": "tests/basic.rs",
"chars": 10844,
"preview": "use std::{\n collections::HashMap,\n ffi::OsStr,\n fs,\n io::{BufRead, BufReader, Read, Write},\n net::{TcpLis"
}
]
About this extraction
This page contains the full source code of the ltratt/pizauth GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 41 files (238.9 KB), approximately 58.2k tokens, and a symbol index with 179 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.