From 0f602a3e1b58f9d4f1da67eec40c3f3d2645a9b0 Mon Sep 17 00:00:00 2001 From: Adam Chalkley Date: Mon, 28 Nov 2022 05:53:45 -0600 Subject: [PATCH] Add "Fetcher" tools for OAuth2 token retrieval - Add utility to obtain OAuth 2 token via Client Credentials flow - Add utility to read OAuth 2 token from file - README updates - add coverage for new tools - misc fixes for previous tooling - Add automatic retry functionality for OAuth2 token retrieval step used by list-emails CLI app and OAuth2-based monitoring plugin - Add winres config to xoauth2 tool (and to new "Fetcher" tools) refs GH-318 refs GH-319 --- .gitignore | 2 + Makefile | 2 + README.md | 210 +++++++++++++++++++---- cmd/check_imap_mailbox_oauth2/process.go | 1 + cmd/fetch-token/doc.go | 21 +++ cmd/fetch-token/main.go | 132 ++++++++++++++ cmd/fetch-token/winres/winres.json | 53 ++++++ cmd/list-emails/process.go | 5 + cmd/read-token/doc.go | 21 +++ cmd/read-token/main.go | 108 ++++++++++++ cmd/read-token/winres/winres.json | 53 ++++++ cmd/xoauth2/main.go | 2 + cmd/xoauth2/winres/winres.json | 53 ++++++ internal/config/config.go | 54 ++++-- internal/config/constants.go | 11 ++ internal/config/file.go | 2 +- internal/config/file_test.go | 4 +- internal/config/flags.go | 28 ++- internal/config/getters.go | 9 + internal/config/logging.go | 14 ++ internal/config/validate.go | 88 ++++++++++ internal/mbxs/auth.go | 19 +- internal/oauth2/doc.go | 9 + internal/oauth2/oauth2.go | 67 ++++++++ 24 files changed, 903 insertions(+), 65 deletions(-) create mode 100644 cmd/fetch-token/doc.go create mode 100644 cmd/fetch-token/main.go create mode 100644 cmd/fetch-token/winres/winres.json create mode 100644 cmd/read-token/doc.go create mode 100644 cmd/read-token/main.go create mode 100644 cmd/read-token/winres/winres.json create mode 100644 cmd/xoauth2/winres/winres.json create mode 100644 internal/oauth2/doc.go create mode 100644 internal/oauth2/oauth2.go diff --git a/.gitignore b/.gitignore index 3dd64399..41756e30 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,8 @@ # Help prevent accidentally including this credentials file in the repo accounts.ini +token.txt +token.json # Ignore log and report files generated by the list-emails application output/ diff --git a/Makefile b/Makefile index 59618aef..eb170588 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,8 @@ WHAT = check_imap_mailbox_basic \ list-emails \ lsimap \ xoauth2 \ + fetch-token \ + read-token \ # TODO: This will need to be standardized across all cmd files in order to # work as intended. diff --git a/README.md b/README.md index 00329173..86207c92 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Various tools used to monitor mail services - [`check_imap_mailbox_*`](#check_imap_mailbox_) - [`list-emails`](#list-emails) - [`lsimap`](#lsimap) + - [`xoauth2`](#xoauth2) + - [`fetch-token`](#fetch-token) + - [`read-token`](#read-token) - [Requirements](#requirements) - [Building source code](#building-source-code) - [Running](#running) @@ -41,8 +44,12 @@ Various tools used to monitor mail services - [Usage](#usage) - [`lsimap`](#lsimap-1) - [Command-line arguments](#command-line-arguments-3) - - [`xoauth2`](#xoauth2) + - [`xoauth2`](#xoauth2-1) - [Command-line arguments](#command-line-arguments-4) + - [`fetch-token`](#fetch-token-1) + - [Command-line arguments](#command-line-arguments-5) + - [`read-token`](#read-token-1) + - [Command-line arguments](#command-line-arguments-6) - [Examples](#examples) - [`check_imap_mailbox_basic`](#check_imap_mailbox_basic-1) - [As a Nagios plugin](#as-a-nagios-plugin) @@ -52,6 +59,9 @@ Various tools used to monitor mail services - [No options](#no-options) - [Alternate locations for config file, log and report directories](#alternate-locations-for-config-file-log-and-report-directories) - [`lsimap`](#lsimap-2) + - [`xoauth2`](#xoauth2-2) + - [`fetch-token`](#fetch-token-2) + - [`read-token`](#read-token-2) - [OAuth 2 Notes](#oauth-2-notes) - [Retrieving a token via curl](#retrieving-a-token-via-curl) - [SASL XOAUTH2 Token encoding](#sasl-xoauth2-token-encoding) @@ -78,13 +88,15 @@ submit improvements for review and potential inclusion into the project. This repo contains various tools used to monitor mail services. -| Tool Name | Overall Status | Description | -| --------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- | -| `check_imap_mailbox_basic` | Stable | Nagios plugin used to monitor mailboxes for items (via Basic Auth) | -| `check_imap_mailbox_oauth2` | Alpha | Nagios plugin used to monitor mailboxes for items (via OAuth2) | -| `list-emails` | Stable | Small CLI app used to generate listing of mailbox contents | -| `lsimap` | Alpha | Small CLI tool to list advertised capabilities for specified IMAP server | -| `xoauth2` | Alpha | Small CLI tool to convert given username and token to XOAuth2 formatted (optionally SASL XOAUTH2 encoded) string | +| Tool Name | Overall Status | Tool Type | Purpose | +| --------------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------- | +| `check_imap_mailbox_basic` | Stable | Nagios plugin | Monitor mailboxes for items (via Basic Auth) | +| `check_imap_mailbox_oauth2` | Alpha | Nagios plugin | Monitor mailboxes for items (via OAuth2) | +| `list-emails` | Stable | CLI app | Generate listing of mailbox contents | +| `lsimap` | Alpha | CLI tool | List advertised capabilities for specified IMAP server | +| `xoauth2` | Alpha | CLI tool | Convert given username and token to XOAuth2 formatted (or SASL XOAUTH2 encoded) string | +| `fetch-token` | Alpha | CLI tool | Fetch OAuth2 Client Credentials token from specified token URL, emit to stdout or file | +| `read-token` | Alpha | CLI tool | Read OAuth2 Client Credentials token from specified file | ## Features @@ -160,6 +172,41 @@ Shared functionality: to IPv4-only or IPv6-only - user-specified minimum TLS version +### `xoauth2` + +Standalone CLI app to convert given username and token to XOAuth2 formatted +(or SASL XOAUTH2 encoded) string. + +### `fetch-token` + +- Fetch OAuth2 Client Credentials token from specified token URL + +- Automatic retry functionality + - user configurable "max attempts" limit +- Emit retrieved token to stdout (default) or file +- Configurable token output format + - plaintext/raw access token + - JSON +- Leveled logging + - `console writer`: human-friendly, but (for this app) non-colorized output + - choice of `disabled`, `panic`, `fatal`, `error`, `warn`, `info` (the + default), `debug` or `trace` + - by default this tool produces no log output + - log messages written to `stderr` + +### `read-token` + +- Read OAuth2 Client Credentials token from specified file +- Automatic detection of support token format + - plaintext/raw access token + - JSON +- Leveled logging + - `console writer`: human-friendly, but (for this app) non-colorized output + - choice of `disabled`, `panic`, `fatal`, `error`, `warn`, `info` (the + default), `debug` or `trace` + - by default this tool produces no log output + - log messages written to `stderr` + ## Requirements The following is a loose guideline. Other combinations of Go and operating @@ -257,6 +304,8 @@ Worth noting: Support for the Client Credentials flow was added 2022-06-30. - `go build -mod=vendor ./cmd/list-emails/` - `go build -mod=vendor ./cmd/lsimap/` - `go build -mod=vendor ./cmd/xoauth2/` + - `go build -mod=vendor ./cmd/fetch-token/` + - `go build -mod=vendor ./cmd/read-token/` - for all supported platforms (where `make` is installed) - `make all` - for Windows @@ -270,6 +319,8 @@ Worth noting: Support for the Client Credentials flow was added 2022-06-30. - look in `/tmp/check-mail/release_assets/list-emails/` - look in `/tmp/check-mail/release_assets/lsimap/` - look in `/tmp/check-mail/release_assets/xoauth2/` + - look in `/tmp/check-mail/release_assets/fetch-token/` + - look in `/tmp/check-mail/release_assets/read-token/` - if using `go build` - look in `/tmp/check-mail/` 1. Copy the applicable binaries to whatever systems needs to run them @@ -277,6 +328,8 @@ Worth noting: Support for the Client Credentials flow was added 2022-06-30. - Place `list-emails` in a location of your choice - Place `lsimap` in a location of your choice - Place `xoauth2` in a location of your choice + - Place `fetch-token` in a location of your choice + - Place `read-token` in a location of your choice - Place `check_imap_mailbox_basic` in the same location where your distro's package manage has place other Nagios plugins - as `/usr/lib/nagios/plugins/check_imap_mailbox_basic` on Debian-based systems @@ -298,6 +351,8 @@ Worth noting: Support for the Client Credentials flow was added 2022-06-30. - Place `list-emails` in a location of your choice - Place `lsimap` in a location of your choice - Place `xoauth2` in a location of your choice + - Place `fetch-token` in a location of your choice + - Place `read-token` in a location of your choice - Place `check_imap_mailbox_basic` in the same location where your distro's package manager places other Nagios plugins - as `/usr/lib/nagios/plugins/check_imap_mailbox_basic` on Debian-based systems @@ -354,20 +409,21 @@ for details specific to using this plugin with O365 mailboxes. - Flags *not* marked as required are for settings where a useful default is already defined. -| Option | Required | Default | Repeat | Possible | Description | -| ---------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | -| `folders` | Yes | *empty string* | No | *comma-separated list of folders* | Folders or IMAP "mailboxes" to check for mail. This value is provided as a comma-separated list. | -| `scopes` | Yes | *empty string* | No | *comma-separated list of scopes* | Permissions needed by the application. If using the scopes defined by the application registration you must use the `RESOURCE/.default` format (e.g., `https://outlook.office365.com/.default`. | -| `client-id` | Yes | *empty string* | No | *valid application ID associated with registered app* | Application (client) ID created during app registration. | -| `client-secret` | Yes | *empty string* | No | *valid application secret associated with registered app* | Client secret (aka, "app" password). | -| `shared-mailbox` | Yes | *empty string* | No | *valid shared mailbox name, often in email address format* | Email account that is to be accessed using client ID & secret values. Usually a shared mailbox among a team. | -| `port` | No | `993` | No | *valid IMAP TCP port* | TCP port used to connect to the remote mail server. This is usually the same port used for TLS encrypted IMAP connections. | -| `net-type` | No | `auto` | No | `auto`, `tcp4`, `tcp6` | Limits network connections to remote mail servers to one of the specified types. | -| `min-tls` | No | `tls12` | No | `tls10`, `tls11`, `tls12`, `tls13` | Limits version of TLS used for connections to remote mail servers. | -| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | -| `branding` | No | `false` | No | `true`, `false` | Toggles emission of branding details with plugin status details. Because this output may not mix well with branding information emitted by other tools, this output is disabled by default. | -| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | +| Option | Required | Default | Repeat | Possible | Description | +| ---------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | +| `folders` | Yes | *empty string* | No | *comma-separated list of folders* | Folders or IMAP "mailboxes" to check for mail. This value is provided as a comma-separated list. | +| `scopes` | Yes | *empty string* | No | *comma-separated list of scopes* | Permissions needed by the application. If using the scopes defined by the application registration you must use the `RESOURCE/.default` format (e.g., `https://outlook.office365.com/.default`. | +| `client-id` | Yes | *empty string* | No | *valid application ID associated with registered app* | Application (client) ID created during app registration. | +| `client-secret` | Yes | *empty string* | No | *valid application secret associated with registered app* | Client secret (aka, "app" password). | +| `shared-mailbox` | Yes | *empty string* | No | *valid shared mailbox name, often in email address format* | Email account that is to be accessed using client ID & secret values. Usually a shared mailbox among a team. | +| `token-url` | Yes | *empty string* | No | *valid token URL* | The OAuth2 provider's token endpoint URL. E.g., `https://accounts.google.com/o/oauth2/token` for Google. See [contrib/list-emails/oauth2/accounts.example.ini](contrib/list-emails/oauth2/accounts.example.ini) for O365 example. | +| `port` | No | `993` | No | *valid IMAP TCP port* | TCP port used to connect to the remote mail server. This is usually the same port used for TLS encrypted IMAP connections. | +| `net-type` | No | `auto` | No | `auto`, `tcp4`, `tcp6` | Limits network connections to remote mail servers to one of the specified types. | +| `min-tls` | No | `tls12` | No | `tls10`, `tls11`, `tls12`, `tls13` | Limits version of TLS used for connections to remote mail servers. | +| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | +| `branding` | No | `false` | No | `true`, `false` | Toggles emission of branding details with plugin status details. Because this output may not mix well with branding information emitted by other tools, this output is disabled by default. | +| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | ### `list-emails` @@ -378,16 +434,16 @@ for details specific to using this plugin with O365 mailboxes. - It is not currently possible to specify all required settings by command-line -| Option | Required | Default | Repeat | Possible | Description | -| ----------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | -| `config-file` | No | `accounts.ini` | No | *valid path to INI configuration file for this application* | Full path to the INI-formatted configuration file used by this application. See contrib/list-emails/accounts.example.ini for a starter template. Rename to accounts.ini, update with applicable information and place in a directory of your choice. If this file is found in your current working directory you need not use this flag. | -| `log-file-dir` | No | `log` | No | *valid, writable path to a directory* | Full path to the directory where log files will be created. The user account running this application requires write permission to this directory. If not specified, a default directory will be created in your current working directory if it does not already exist. | -| `report-file-dir` | No | `output` | No | *valid, writable path to a directory* | Full path to the directory where email summary report files will be created. The user account running this application requires write permission to this directory. If not specified, a default directory will be created in your current working directory if it does not already exist. | -| `net-type` | No | `auto` | No | `auto`, `tcp4`, `tcp6` | Limits network connections to remote mail servers to one of the specified types. | -| `min-tls` | No | `tls12` | No | `tls10`, `tls11`, `tls12`, `tls13` | Limits version of TLS used for connections to remote mail servers. | -| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | -| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | +| Option | Required | Default | Repeat | Possible | Description | +| ----------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | +| `config-file` | No | `accounts.ini` | No | *valid path to INI configuration file for this application* | Full path to the INI-formatted configuration file used by this application. See [contrib/list-emails/](contrib/list-emails/) for starter templates. Rename to accounts.ini, update with applicable information and place in a directory of your choice. If this file is found in your current working directory you need not use this flag. | +| `log-file-dir` | No | `log` | No | *valid, writable path to a directory* | Full path to the directory where log files will be created. The user account running this application requires write permission to this directory. If not specified, a default directory will be created in your current working directory if it does not already exist. | +| `report-file-dir` | No | `output` | No | *valid, writable path to a directory* | Full path to the directory where email summary report files will be created. The user account running this application requires write permission to this directory. If not specified, a default directory will be created in your current working directory if it does not already exist. | +| `net-type` | No | `auto` | No | `auto`, `tcp4`, `tcp6` | Limits network connections to remote mail servers to one of the specified types. | +| `min-tls` | No | `tls12` | No | `tls10`, `tls11`, `tls12`, `tls13` | Limits version of TLS used for connections to remote mail servers. | +| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | +| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | #### Configuration file @@ -493,6 +549,42 @@ You may also place the file wherever you like and refer to it using the | `token` | Yes | *empty string* | No | *valid token* | Access token. | | `encode` | No | `false` | No | `true`, `false` | Whether to encode XOAuth2 string for use in SASL XOAUTH2. | +### `fetch-token` + +#### Command-line arguments + +- Flags marked as **`required`** must be set via CLI flag. +- Flags *not* marked as required are for settings where a useful default is + already defined. + +| Option | Required | Default | Repeat | Possible | Description | +| --------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | +| `scopes` | Yes | *empty string* | No | *comma-separated list of scopes* | Permissions needed by the application. If using the scopes defined by the application registration you must use the `RESOURCE/.default` format (e.g., `https://outlook.office365.com/.default`. | +| `client-id` | Yes | *empty string* | No | *valid application ID associated with registered app* | Application (client) ID created during app registration. | +| `client-secret` | Yes | *empty string* | No | *valid application secret associated with registered app* | Client secret (aka, "app" password). | +| `token-url` | Yes | *empty string* | No | *valid token URL* | The OAuth2 provider's token endpoint URL. E.g., `https://accounts.google.com/o/oauth2/token` for Google. See [contrib/list-emails/oauth2/accounts.example.ini](contrib/list-emails/oauth2/accounts.example.ini) for O365 example. | +| `filename` | No | *empty string* | No | *valid path to file* | Optional file used to record a retrieved token. If specified the file will be overwritten. | +| `json-output` | No | `false` | No | `true`, `false` | Emit retrieved token in JSON format. Defaults to emitting the access token field from retrieved payload. | +| `max-attempts` | No | `3` | No | *positive whole number* | Max token retrieval attempts. | +| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | +| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | + +### `read-token` + +#### Command-line arguments + +- Flags marked as **`required`** must be set via CLI flag. +- Flags *not* marked as required are for settings where a useful default is + already defined. + +| Option | Required | Default | Repeat | Possible | Description | +| --------------- | -------- | -------------- | ------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `h`, `help` | No | | No | `-h`, `--help` | Generate listing of all valid command-line options and applicable (short) guidance for using them. | +| `filename` | Yes | *empty string* | No | *valid path to file* | File o used to record a retrieved token. If specified the file will be overwritten. | +| `logging-level` | No | `info` | No | `disabled`, `panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | Sets log level. | +| `version` | No | `false` | No | `true`, `false` | Whether to display application version and then immediately exit application | + ## Examples ### `check_imap_mailbox_basic` @@ -618,6 +710,60 @@ $ ./lsimap --server imap.gmail.com 6:10AM INF cmd\lsimap\main.go:95 > Connection to server closed ``` +### `xoauth2` + +```console +export user="me@there.com" +export token="adfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdfa" +$ ./xoauth2 --token "$token" --username "$user" > go-output.txt +$ cat go-output.txt +dXNlcj1tZUB0aGVyZS5jb20BYXV0aD1CZWFyZXIgYWRmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYXNkZmFzZGZhc2RmYQEB +``` + +### `fetch-token` + +```console +$ ./fetch-token \ + --client-id 'ZYDPLLBWSK3MVQJSIYHB1OR2JXCY0X2C5UJ2QAR2MAAIT5Q' \ + --client-secret '_djgA8heFo0WSIMom7U39WmGTQFHWkcD8x-A1o-4sro' \ + --scopes 'https://outlook.office365.com/.default' \ + --token-url 'https://login.microsoftonline.com/6029c1d9-aa2f-4227-8f7c-0c23224a0fa9/oauth2/v2.0/token' \ + --log-level debug \ + --filename "token.txt" +1:15PM DBG cmd\fetch-token\main.go:62 > Application configuration initialized filename=token.txt +1:15PM DBG cmd\fetch-token\main.go:64 > Fetching Client Credentials token filename=token.txt +1:15PM DBG cmd\fetch-token\main.go:77 > Token retrieved filename=token.txt +1:15PM DBG cmd\fetch-token\main.go:114 > Successfully wrote data to file filename=token.txt +``` + +This resulted in a plaintext token being written to `token.txt` for later +retrieval by the `read-token` utility, or even `cat` or similar shell +scripting approach. + +If saving the token in JSON format via the `--json-output` flag (e.g., if you +want to also retain the token metadata), the `read-token` utility is provided +to read back just the access token portion of the saved value. + +### `read-token` + +```console +$ ./read-token --filename "token.txt" --log-level debug +1:15PM DBG cmd\read-token\main.go:54 > Application configuration initialized filename=token.txt +1:15PM DBG cmd\read-token\main.go:56 > Fetching Client Credentials token from file filename=token.txt +1:15PM DBG cmd\read-token\main.go:62 > Successfully read contents of file filename=token.txt +1:15PM DBG cmd\read-token\main.go:90 > File contents do not appear to be JSON filename=token.txt +1:15PM DBG cmd\read-token\main.go:91 > Attempting to parse file contents as plaintext access token filename=token.txt +PLACEHOLDER1:15PM DBG cmd\read-token\main.go:102 > Emitted retrieved token bytes_written=1508 filename=token.txt +``` + +The `PLACEHOLDER` value above indicates the access token emitted on `stdout`. +It is interleaved with the log message emitted on stderr which immediately +follows the token. + +If redirecting `stderr` to a file, disabling log messages entirely (or if no +errors are encountered), log messages will not intermix with the emitted token +on `stdout`. + ## OAuth 2 Notes Misc bits of info that don't fit well anywhere else. Potentially slated for diff --git a/cmd/check_imap_mailbox_oauth2/process.go b/cmd/check_imap_mailbox_oauth2/process.go index 8f117152..c02e7b93 100644 --- a/cmd/check_imap_mailbox_oauth2/process.go +++ b/cmd/check_imap_mailbox_oauth2/process.go @@ -65,6 +65,7 @@ func processAccount( account.OAuth2Settings.ClientSecret, account.OAuth2Settings.Scopes, account.OAuth2Settings.TokenURL, + cfg.RetrievalAttempts(), logger, ) if loginErr != nil { diff --git a/cmd/fetch-token/doc.go b/cmd/fetch-token/doc.go new file mode 100644 index 00000000..34bcba2c --- /dev/null +++ b/cmd/fetch-token/doc.go @@ -0,0 +1,21 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Small CLI app used to fetch an OAuth2 Client Credentials token. The intent +// is to provide a tool that allows retrieving a token via a cron job and +// caching it for later use. Optionally, the token can be used immediately +// from a shell script. +// +// See our [GitHub repo]: +// +// - to review documentation (including examples) +// - for the latest code +// - to file an issue or submit improvements for review and potential +// inclusion into the project +// +// [GitHub repo]: https://github.com/atc0005/check-mail +package main diff --git a/cmd/fetch-token/main.go b/cmd/fetch-token/main.go new file mode 100644 index 00000000..9490ad5e --- /dev/null +++ b/cmd/fetch-token/main.go @@ -0,0 +1,132 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +//go:generate go-winres make --product-version=git-tag --file-version=git-tag + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/atc0005/check-mail/internal/config" + "github.com/atc0005/check-mail/internal/oauth2" + "github.com/rs/zerolog" +) + +func main() { + + ctx := context.Background() + + // Setup configuration by parsing user-provided flags + cfg, cfgErr := config.New(config.AppType{FetcherOAuth2TokenFromAuthServer: true}) + switch { + case errors.Is(cfgErr, config.ErrVersionRequested): + fmt.Println(config.Version()) + + return + + case errors.Is(cfgErr, config.ErrHelpRequested): + fmt.Println(cfg.Help()) + + return + + case cfgErr != nil: + + // We make some assumptions when setting up our logger as we do not + // have a working configuration based on sysadmin-specified choices. + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} + logger := zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() + + logger.Err(cfgErr).Msg("Error initializing application") + + return + } + + var logger zerolog.Logger + switch { + case cfg.FetcherOAuth2TokenSettings.Filename != "": + logger = cfg.Log.With(). + Str("filename", cfg.FetcherOAuth2TokenSettings.Filename). + Logger() + default: + logger = cfg.Log.With().Logger() + } + + logger.Debug().Msg("Application configuration initialized") + + logger.Debug().Msg("Fetching Client Credentials token") + token, err := oauth2.GetClientCredentialsToken( + ctx, + cfg.FetcherOAuth2TokenSettings.ClientID, + cfg.FetcherOAuth2TokenSettings.ClientSecret, + cfg.FetcherOAuth2TokenSettings.Scopes, + cfg.FetcherOAuth2TokenSettings.TokenURL, + cfg.RetrievalAttempts(), + ) + if err != nil { + logger.Error().Err(err).Msg("Failed to retrieve token") + os.Exit(1) + } + logger.Debug(). + Str("token_expiration", token.Expiry.Format(time.RFC3339)). + Str("token_type", token.Type()). + Msg("Token retrieved") + + var data []byte + var emittedAsJSON bool + switch { + + case cfg.FetcherOAuth2TokenSettings.EmitTokenAsJSON: + var err error + data, err = json.MarshalIndent(token, "", "\t") + if err != nil { + logger.Error(). + Err(err). + Msg("Failed to marshal token to JSON format") + os.Exit(1) + } + logger.Debug().Msg("Successfully converted token to JSON") + + emittedAsJSON = true + + default: + logger.Debug().Msg("Retaining access token as plaintext value") + data = []byte(token.AccessToken) + emittedAsJSON = false + } + + switch { + case cfg.FetcherOAuth2TokenSettings.Filename != "": + err := os.WriteFile(filepath.Clean(cfg.FetcherOAuth2TokenSettings.Filename), data, 0600) + if err != nil { + logger.Error(). + Err(err). + Msg("Failed to write data to output file") + os.Exit(1) + } + + logger.Debug().Msg("Successfully wrote data to file") + + default: + n, err := os.Stdout.Write(data) + if err != nil { + logger.Error().Err(err).Msg("Failed to write data to stdout") + os.Exit(1) + } + logger.Debug(). + Int("bytes_written", n). + Bool("emitted_as_json", emittedAsJSON). + Msg("Emitted retrieved token") + } + +} diff --git a/cmd/fetch-token/winres/winres.json b/cmd/fetch-token/winres/winres.json new file mode 100644 index 00000000..7e181615 --- /dev/null +++ b/cmd/fetch-token/winres/winres.json @@ -0,0 +1,53 @@ +{ + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "Small CLI tool to fetch OAuth2 Client Credentials token from specified token URL", + "minimum-os": "win7", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "0.0.0.0", + "product_version": "0.0.0.0" + }, + "info": { + "0409": { + "Comments": "Part of the atc0005/check-mail project", + "CompanyName": "github.com/atc0005", + "FileDescription": "Small CLI tool to fetch OAuth2 Client Credentials token from specified token URL", + "FileVersion": "", + "InternalName": "fetch-token", + "LegalCopyright": "© Adam Chalkley. Licensed under MIT.", + "LegalTrademarks": "", + "OriginalFilename": "main.go", + "PrivateBuild": "", + "ProductName": "check-mail", + "ProductVersion": "", + "SpecialBuild": "" + } + } + } + } + } +} diff --git a/cmd/list-emails/process.go b/cmd/list-emails/process.go index 76d2c4aa..ad5e47e5 100644 --- a/cmd/list-emails/process.go +++ b/cmd/list-emails/process.go @@ -93,6 +93,11 @@ func processAccount( account.OAuth2Settings.ClientSecret, account.OAuth2Settings.Scopes, account.OAuth2Settings.TokenURL, + + // We're going to use the default/fallback value instead of + // exposing a max retrieval attempts flag or attempting to pull + // the value from a config file. + cfg.RetrievalAttempts(), logger, ) if loginErr != nil { diff --git a/cmd/read-token/doc.go b/cmd/read-token/doc.go new file mode 100644 index 00000000..9bd6da3e --- /dev/null +++ b/cmd/read-token/doc.go @@ -0,0 +1,21 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Small CLI app used to read an OAuth2 Client Credentials token from a file +// for use within a shell script. A separate tool is used to retrieve the +// token from an authority (e.g., via a cron job) and cache it for this tool +// to read. +// +// See our [GitHub repo]: +// +// - to review documentation (including examples) +// - for the latest code +// - to file an issue or submit improvements for review and potential +// inclusion into the project +// +// [GitHub repo]: https://github.com/atc0005/check-mail +package main diff --git a/cmd/read-token/main.go b/cmd/read-token/main.go new file mode 100644 index 00000000..79eb58c6 --- /dev/null +++ b/cmd/read-token/main.go @@ -0,0 +1,108 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +//go:generate go-winres make --product-version=git-tag --file-version=git-tag + +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/atc0005/check-mail/internal/config" + "github.com/rs/zerolog" + "golang.org/x/oauth2" +) + +func main() { + + // Setup configuration by parsing user-provided flags + cfg, cfgErr := config.New(config.AppType{FetcherOAuth2TokenFromCache: true}) + switch { + case errors.Is(cfgErr, config.ErrVersionRequested): + fmt.Println(config.Version()) + + return + + case errors.Is(cfgErr, config.ErrHelpRequested): + fmt.Println(cfg.Help()) + + return + + case cfgErr != nil: + + // We make some assumptions when setting up our logger as we do not + // have a working configuration based on sysadmin-specified choices. + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} + logger := zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() + + logger.Err(cfgErr).Msg("Error initializing application") + + return + } + + logger := cfg.Log.With(). + Str("filename", cfg.FetcherOAuth2TokenSettings.Filename). + Logger() + + logger.Debug().Msg("Application configuration initialized") + + logger.Debug().Msg("Fetching Client Credentials token from file") + data, err := os.ReadFile(cfg.FetcherOAuth2TokenSettings.Filename) + if err != nil { + logger.Error().Err(err).Msg("Failed to read file contents") + os.Exit(1) + } + logger.Debug().Msg("Successfully read file contents") + + var output []byte + switch { + case bytes.Contains(data, []byte("{")): + logger.Error().Err(err).Msg("File contents appear to be JSON, will attempt to parse as JSON") + + var token oauth2.Token + if err := json.Unmarshal(data, &token); err != nil { + logger.Error().Err(err).Msg("Failed to parse file contents as JSON") + os.Exit(1) + } + logger.Debug().Msg("Successfully parsed file contents as JSON") + + if !token.Valid() { + logger.Error(). + Str("token_expiration", token.Expiry.Format(time.RFC3339)). + Str("token_type", token.Type()). + Msg("Token is NOT valid; a new token should be retrieved and cached in file") + os.Exit(1) + } + + logger.Debug(). + Str("token_expiration", token.Expiry.Format(time.RFC3339)). + Str("token_type", token.Type()). + Msg("Token is valid, retrieving access token value") + + output = []byte(token.AccessToken) + + default: + logger.Debug().Msg("File contents do not appear to be JSON") + logger.Debug().Msg("Attempting to parse file contents as plaintext access token") + output = data + } + + n, err := os.Stdout.Write(output) + if err != nil { + logger.Error().Err(err).Msg("Failed to emit token") + os.Exit(1) + } + logger.Debug(). + Int("bytes_written", n). + Msg("Emitted retrieved token") + +} diff --git a/cmd/read-token/winres/winres.json b/cmd/read-token/winres/winres.json new file mode 100644 index 00000000..a413b7a5 --- /dev/null +++ b/cmd/read-token/winres/winres.json @@ -0,0 +1,53 @@ +{ + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "Small CLI tool to read OAuth2 Client Credentials token from specified file", + "minimum-os": "win7", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "0.0.0.0", + "product_version": "0.0.0.0" + }, + "info": { + "0409": { + "Comments": "Part of the atc0005/check-mail project", + "CompanyName": "github.com/atc0005", + "FileDescription": "Small CLI tool to read OAuth2 Client Credentials token from specified file", + "FileVersion": "", + "InternalName": "read-token", + "LegalCopyright": "© Adam Chalkley. Licensed under MIT.", + "LegalTrademarks": "", + "OriginalFilename": "main.go", + "PrivateBuild": "", + "ProductName": "check-mail", + "ProductVersion": "", + "SpecialBuild": "" + } + } + } + } + } +} diff --git a/cmd/xoauth2/main.go b/cmd/xoauth2/main.go index 6538e6de..fd6e464d 100644 --- a/cmd/xoauth2/main.go +++ b/cmd/xoauth2/main.go @@ -5,6 +5,8 @@ // Licensed under the MIT License. See LICENSE file in the project root for // full license information. +//go:generate go-winres make --product-version=git-tag --file-version=git-tag + package main import ( diff --git a/cmd/xoauth2/winres/winres.json b/cmd/xoauth2/winres/winres.json new file mode 100644 index 00000000..99a382da --- /dev/null +++ b/cmd/xoauth2/winres/winres.json @@ -0,0 +1,53 @@ +{ + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "Small CLI tool to convert given username and token to XOAuth2 formatted (or SASL XOAUTH2 encoded) string", + "minimum-os": "win7", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "system", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "0.0.0.0", + "product_version": "0.0.0.0" + }, + "info": { + "0409": { + "Comments": "Part of the atc0005/check-mail project", + "CompanyName": "github.com/atc0005", + "FileDescription": "Small CLI tool to convert given username and token to XOAuth2 formatted (or SASL XOAUTH2 encoded) string", + "FileVersion": "", + "InternalName": "xoauth2", + "LegalCopyright": "© Adam Chalkley. Licensed under MIT.", + "LegalTrademarks": "", + "OriginalFilename": "main.go", + "PrivateBuild": "", + "ProductName": "check-mail", + "ProductVersion": "", + "SpecialBuild": "" + } + } + } + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index be1edd78..b40e7f0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,15 @@ type AppType struct { // An OAuth2 flow is used to login. PluginIMAPMailboxOAuth2 bool + // FetcherOAuth2TokenFromCache represents an application used to obtain an + // OAuth2 token via Client Credentials flow from local storage/cache. + FetcherOAuth2TokenFromCache bool + + // FetcherOAuth2TokenFromAuthServer represents an application used to + // obtain an OAuth2 token via Client Credentials flow from an + // authorization server. + FetcherOAuth2TokenFromAuthServer bool + // ReporterIMAPMailbox represents an application used for generating // reports for specified IMAP mailboxes. // @@ -87,9 +96,9 @@ type AppType struct { InspectorIMAPCaps bool } -// OAuth2MailAccountSettings is a collection of OAuth2 settings for a mail -// account that applications in this project interact with. -type OAuth2MailAccountSettings struct { +// OAuth2ClientCredentialsFlow is a collection of OAuth2 settings used to +// obtain a token via OAuth2 Client Credentials flow. +type OAuth2ClientCredentialsFlow struct { // ClientID is the client ID used by the application that asks for // authorization. It must be unique across all clients that the // authorization server handles. This ID represents the registration @@ -99,12 +108,12 @@ type OAuth2MailAccountSettings struct { // owner and MUST NOT be used alone for client authentication. The client // identifier is unique to the authorization server. // https://datatracker.ietf.org/doc/html/rfc6749#section-2.2 - ClientID string `json:"client_id"` + ClientID string // ClientSecret is a secret known only to the application and the // authorization server. It can be considered the application's own // password. This value is provided upon application authorization. - ClientSecret string `json:"client_secret"` + ClientSecret string // Scopes is the collection of permissions or "scopes" requested by an // application from the authorization server. @@ -115,18 +124,12 @@ type OAuth2MailAccountSettings struct { // // https://www.oauth.com/oauth2-servers/scope/ // https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps - Scopes multiValueFlag `json:"scope,omitempty"` - - // TenantID is the tenant or customer identifier associated with the - // OAuth2-enabled service. For example, with Microsoft Office 365 (O365) - // this value is used to represent the organization subscribed to O365 - // services. - // TenantID string `json:"-"` + Scopes multiValueFlag // SharedMailbox is the email account that is to be accessed by the // application using the given client ID, client secret values. This is // usually a shared mailbox among a team. - SharedMailbox string `json:"-"` + SharedMailbox string // Token is a valid XOAUTH2 encoded token to use in place of requesting a // new token from the authorization server. If specified, Token obviates @@ -138,10 +141,27 @@ type OAuth2MailAccountSettings struct { // TODO: Does this provide sufficient value? Tradeoff of token reuse (and // everything required to save/load it) vs fetching a new token ... // - // Token string `json:"-"` + // Token string // TokenURL is the authority endpoint for token retrieval. TokenURL string + + // RetrievalAttempts indicates how many attempts should be made to + // retrieve a token. + RetrievalAttempts int +} + +// FetcherOAuth2TokenSettings is the collection of OAuth2 token "fetcher" +// settings. +type FetcherOAuth2TokenSettings struct { + OAuth2ClientCredentialsFlow + + // Filename is the optional filename used to hold a retrieved token. + Filename string + + // EmitTokenAsJSON indicates whether the retrieved token is saved in + // the original JSON payload format or as just the access token itself. + EmitTokenAsJSON bool } // MailAccount represents an email account. The values are provided via @@ -175,7 +195,7 @@ type MailAccount struct { // OAuth2Settings is a collection of settings specific to OAuth2 // authentication with the service hosting the email account. - OAuth2Settings OAuth2MailAccountSettings + OAuth2Settings OAuth2ClientCredentialsFlow // Name is often the bare username for the email account, but may not be. // This is used as the section header within the configuration file. @@ -285,6 +305,10 @@ type Config struct { // applications provided by this project. Accounts []MailAccount + // FetcherOAuth2TokenSettings is the collection of OAuth2 token "fetcher" + // settings. + FetcherOAuth2TokenSettings FetcherOAuth2TokenSettings + // Log is an embedded zerolog Logger initialized via config.New(). Log zerolog.Logger } diff --git a/internal/config/constants.go b/internal/config/constants.go index 88405978..a43cddd7 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -56,6 +56,13 @@ const ( logFileOutputDirFlagHelp string = "Full path to the directory where log files will be created. The user account running this application requires write permission to this directory. If not specified, a default directory will be created in your current working directory if it does not already exist." ) +// Fetcher flag help text +const ( + emitTokenAsJSONFlagHelp string = "Emit retrieved token in JSON format. Defaults to emitting the access token field from retrieved payload." + tokenFilenameFlagHelp string = "Save retrieved token to specified file. Emitted to standard out (stdout) if not specified." + tokenRetrievalAttemptsFlagHelp string = "Max token retrieval attempts." +) + // Default flag settings if not overridden by user input const ( defaultHelp bool = false @@ -72,6 +79,8 @@ const ( defaultNetworkType string = netTypeTCPAuto defaultMinTLSVersion string = minTLSVersion12 defaultDisplayVersionAndExit bool = false + defaultEmitTokenAsJSON bool = false + defaultTokenFilename string = "" // By default these directories are created/used in the user's current // working directory. The workflow for the older, Python-based list-emails @@ -102,6 +111,8 @@ const ( // defaultTOMLConfigFileName string = "config.toml" defaultAccountProcessDelay time.Duration = time.Second * 5 + + defaultTokenRetrievalAttempts int = 3 ) const ( diff --git a/internal/config/file.go b/internal/config/file.go index 6e1a1853..f1025f94 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -380,7 +380,7 @@ func (c *Config) parseConfigFile(file []byte) error { Server: serverName, Username: username, Password: password, - OAuth2Settings: OAuth2MailAccountSettings{ + OAuth2Settings: OAuth2ClientCredentialsFlow{ ClientID: clientID, ClientSecret: clientSecret, Scopes: scopes, diff --git a/internal/config/file_test.go b/internal/config/file_test.go index a70ddc35..75127240 100644 --- a/internal/config/file_test.go +++ b/internal/config/file_test.go @@ -202,7 +202,7 @@ func TestValidateOAuth2ExampleConfigFile(t *testing.T) { "Trash", "INBOX/Current Reporting", }, - OAuth2Settings: OAuth2MailAccountSettings{ + OAuth2Settings: OAuth2ClientCredentialsFlow{ SharedMailbox: "email1@example.com", ClientID: "ZYDPLLBWSK3MVQJSIYHB1OR2JXCY0X2C5UJ2QAR2MAAIT5Q", ClientSecret: "_djgA8heFo0WSIMom7U39WmGTQFHWkcD8x-A1o-4sro", @@ -219,7 +219,7 @@ func TestValidateOAuth2ExampleConfigFile(t *testing.T) { "Inbox", "Junk EMail", }, - OAuth2Settings: OAuth2MailAccountSettings{ + OAuth2Settings: OAuth2ClientCredentialsFlow{ SharedMailbox: "email2@example.com", ClientID: "ZYDPLLBWSK3MVQJSIYHB1OR2JXCY0X2C5UJ2QAR2MAAIT5Q", ClientSecret: "_djgA8heFo0WSIMom7U39WmGTQFHWkcD8x-A1o-4sro", diff --git a/internal/config/flags.go b/internal/config/flags.go index 4b96f359..06c2d6a9 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -34,23 +34,36 @@ func (c *Config) handleFlagsConfig(appType AppType) error { c.flagSet.BoolVar(&c.ShowVersion, "version", defaultDisplayVersionAndExit, versionFlagHelp) c.flagSet.StringVar(&c.LoggingLevel, "log-level", defaultLoggingLevel, loggingLevelFlagHelp) - c.flagSet.StringVar(&c.NetworkType, "net-type", defaultNetworkType, networkTypeFlagHelp) - c.flagSet.StringVar(&c.minTLSVersion, "min-tls", defaultMinTLSVersion, minTLSVersionFlagHelp) - // Only applies to Reporter app if appType.ReporterIMAPMailbox { + c.flagSet.StringVar(&c.minTLSVersion, "min-tls", defaultMinTLSVersion, minTLSVersionFlagHelp) + c.flagSet.StringVar(&c.NetworkType, "net-type", defaultNetworkType, networkTypeFlagHelp) c.flagSet.StringVar(&c.ConfigFile, "config-file", defaultINIConfigFileName, iniConfigFileFlagHelp) c.flagSet.StringVar(&c.ReportFileOutputDir, "report-file-dir", defaultReportFileOutputDir, reportFileOutputDirFlagHelp) c.flagSet.StringVar(&c.LogFileOutputDir, "log-file-dir", defaultLogFileOutputDir, logFileOutputDirFlagHelp) } - // Inspector app if appType.InspectorIMAPCaps { c.flagSet.StringVar(&account.Server, "server", defaultServer, serverFlagHelp) c.flagSet.IntVar(&account.Port, "port", defaultPort, portFlagHelp) + c.flagSet.StringVar(&c.minTLSVersion, "min-tls", defaultMinTLSVersion, minTLSVersionFlagHelp) + c.flagSet.StringVar(&c.NetworkType, "net-type", defaultNetworkType, networkTypeFlagHelp) + } + + if appType.FetcherOAuth2TokenFromAuthServer { + c.flagSet.Var(&c.FetcherOAuth2TokenSettings.Scopes, "scopes", scopesFlagHelp) + c.flagSet.StringVar(&c.FetcherOAuth2TokenSettings.ClientID, "client-id", defaultClientID, clientIDFlagHelp) + c.flagSet.StringVar(&c.FetcherOAuth2TokenSettings.ClientSecret, "client-secret", defaultClientSecret, clientSecretFlagHelp) + c.flagSet.StringVar(&c.FetcherOAuth2TokenSettings.TokenURL, "token-url", defaultTokenURL, tokenURLFlagHelp) + c.flagSet.BoolVar(&c.FetcherOAuth2TokenSettings.EmitTokenAsJSON, "json-output", defaultEmitTokenAsJSON, emitTokenAsJSONFlagHelp) + c.flagSet.StringVar(&c.FetcherOAuth2TokenSettings.Filename, "filename", defaultTokenFilename, tokenFilenameFlagHelp) + c.flagSet.IntVar(&c.FetcherOAuth2TokenSettings.RetrievalAttempts, "max-attempts", defaultTokenRetrievalAttempts, tokenRetrievalAttemptsFlagHelp) + } + + if appType.FetcherOAuth2TokenFromCache { + c.flagSet.StringVar(&c.FetcherOAuth2TokenSettings.Filename, "filename", defaultTokenFilename, tokenFilenameFlagHelp) } - // Basic Auth Plugin if appType.PluginIMAPMailboxBasicAuth { // Indicate what validation logic should be applied for this set of @@ -63,9 +76,10 @@ func (c *Config) handleFlagsConfig(appType AppType) error { c.flagSet.StringVar(&account.Server, "server", defaultServer, serverFlagHelp) c.flagSet.IntVar(&account.Port, "port", defaultPort, portFlagHelp) c.flagSet.BoolVar(&c.EmitBranding, "branding", defaultEmitBranding, emitBrandingFlagHelp) + c.flagSet.StringVar(&c.minTLSVersion, "min-tls", defaultMinTLSVersion, minTLSVersionFlagHelp) + c.flagSet.StringVar(&c.NetworkType, "net-type", defaultNetworkType, networkTypeFlagHelp) } - // OAuth2 Client Credentials flow plugin if appType.PluginIMAPMailboxOAuth2 { // Indicate what validation logic should be applied for this set of @@ -77,6 +91,8 @@ func (c *Config) handleFlagsConfig(appType AppType) error { c.flagSet.StringVar(&account.Server, "server", defaultServer, serverFlagHelp) c.flagSet.IntVar(&account.Port, "port", defaultPort, portFlagHelp) c.flagSet.BoolVar(&c.EmitBranding, "branding", defaultEmitBranding, emitBrandingFlagHelp) + c.flagSet.StringVar(&c.minTLSVersion, "min-tls", defaultMinTLSVersion, minTLSVersionFlagHelp) + c.flagSet.StringVar(&c.NetworkType, "net-type", defaultNetworkType, networkTypeFlagHelp) // OAuth2 flags c.flagSet.Var(&account.OAuth2Settings.Scopes, "scopes", scopesFlagHelp) diff --git a/internal/config/getters.go b/internal/config/getters.go index 5076e1f0..09dfd2bc 100644 --- a/internal/config/getters.go +++ b/internal/config/getters.go @@ -110,3 +110,12 @@ func (c Config) AccountNames() []string { return accountsList } + +// RetrievalAttempts returns the configured retrieval attempts or the default +// value if not specified. +func (c Config) RetrievalAttempts() int { + if c.FetcherOAuth2TokenSettings.RetrievalAttempts <= 0 { + return defaultTokenRetrievalAttempts + } + return c.FetcherOAuth2TokenSettings.RetrievalAttempts +} diff --git a/internal/config/logging.go b/internal/config/logging.go index cc1a738b..b8677f5c 100644 --- a/internal/config/logging.go +++ b/internal/config/logging.go @@ -145,6 +145,20 @@ func (c *Config) setupLogging(appType AppType) error { consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr} c.Log = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() + case appType.FetcherOAuth2TokenFromAuthServer: + + // Slimline logger; omit color/formatting as this isn't handled well + // when logged to text files. + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true} + c.Log = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() + + case appType.FetcherOAuth2TokenFromCache: + + // Slimline logger; omit color/formatting as this isn't handled well + // when logged to text files. + consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true} + c.Log = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() + case appType.PluginIMAPMailboxBasicAuth: // Whatever output meant for consumption is emitted to stdout and diff --git a/internal/config/validate.go b/internal/config/validate.go index b455a8a0..3ee141e7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -123,6 +123,68 @@ func validateAccountOAuth2ClientCredsAuthFields(account MailAccount, appType App return nil } +// validateAccountOAuth2ClientCredsAuthFields is responsible for validating +// MailAccount fields specific to the OAuth2 Client Credentials Flow +// authentication type. The caller is responsible for calling this function +// for the appropriate application type. +func validateFetcherOAuth2TokenFields(tokenSettings FetcherOAuth2TokenSettings, appType AppType) error { + + switch { + case appType.FetcherOAuth2TokenFromAuthServer: + if tokenSettings.ClientID == "" { + return fmt.Errorf("client ID not provided") + } + + if tokenSettings.ClientSecret == "" { + return fmt.Errorf("client secret not provided") + } + + // Scopes is non-optional. If we want to support just *one* IMAP provider + // (e.g., O365) we can fallback to using a default scope, but if the goal + // is (it is) to support multiple providers we need to require at least + // one scope value. + if len(tokenSettings.Scopes) == 0 { + return fmt.Errorf("scopes not provided") + } + + // Unlikely to have empty slice strings, but worth ruling out? + for _, scope := range tokenSettings.Scopes { + if strings.TrimSpace(scope) == "" { + return fmt.Errorf("empty scope provided") + } + } + + if tokenSettings.TokenURL == "" { + return fmt.Errorf("token URL not provided") + } + + if tokenSettings.RetrievalAttempts <= 0 { + return fmt.Errorf( + "invalid token retrieval retry attempts value: %d", + tokenSettings.RetrievalAttempts, + ) + } + + case appType.FetcherOAuth2TokenFromCache: + + // The filename to read a token from is only required for this specific + // application type. The other Fetcher app type reads the token from the + // authority and *optionally* writes it to a file. + if tokenSettings.Filename == "" { + return fmt.Errorf("filename not provided") + + } + + default: + return fmt.Errorf( + "unable to validate configuration: %w", + ErrAppTypeNotSpecified, + ) + } + + return nil +} + // validateAccounts is responsible for validating MailAccount fields. func validateAccounts(c Config, appType AppType) error { for _, account := range c.Accounts { @@ -311,6 +373,32 @@ func (c Config) validate(appType AppType) error { return err } + case appType.FetcherOAuth2TokenFromAuthServer: + + if err := validateLoggingLevels(c); err != nil { + return err + } + + if err := validateFetcherOAuth2TokenFields( + c.FetcherOAuth2TokenSettings, + appType, + ); err != nil { + return err + } + + case appType.FetcherOAuth2TokenFromCache: + + if err := validateLoggingLevels(c); err != nil { + return err + } + + if err := validateFetcherOAuth2TokenFields( + c.FetcherOAuth2TokenSettings, + appType, + ); err != nil { + return err + } + default: return fmt.Errorf( "unable to validate configuration: %w", diff --git a/internal/mbxs/auth.go b/internal/mbxs/auth.go index 5aeb2619..a77a1286 100644 --- a/internal/mbxs/auth.go +++ b/internal/mbxs/auth.go @@ -13,10 +13,10 @@ import ( "fmt" "time" + "github.com/atc0005/check-mail/internal/oauth2" "github.com/atc0005/check-mail/internal/sasl" "github.com/emersion/go-imap/client" "github.com/rs/zerolog" - "golang.org/x/oauth2/clientcredentials" ) var ( @@ -110,6 +110,7 @@ func OAuth2ClientCredsAuth( clientSecret string, scopes []string, tokenEndpointURL string, + maxAttempts int, logger zerolog.Logger, ) error { @@ -131,15 +132,15 @@ func OAuth2ClientCredsAuth( logger.Warn().Msg("WARNING: Connection to server is insecure (TLS is not enabled)") } - oauth2Config := clientcredentials.Config{ - ClientID: clientID, - ClientSecret: clientSecret, - TokenURL: tokenEndpointURL, - Scopes: scopes, - } - logger.Debug().Msg("Acquiring fresh token") - token, err := oauth2Config.Token(ctx) + token, err := oauth2.GetClientCredentialsToken( + ctx, + clientID, + clientSecret, + scopes, + tokenEndpointURL, + maxAttempts, + ) if err != nil { logger.Debug().Err(err).Msg("Failed to retrieve token") return fmt.Errorf( diff --git a/internal/oauth2/doc.go b/internal/oauth2/doc.go new file mode 100644 index 00000000..f9b79e1f --- /dev/null +++ b/internal/oauth2/doc.go @@ -0,0 +1,9 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +// Package oauth2 provides OAuth 2 abstractions for this project. +package oauth2 diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go new file mode 100644 index 00000000..6c1249e3 --- /dev/null +++ b/internal/oauth2/oauth2.go @@ -0,0 +1,67 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/check-mail +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package oauth2 + +import ( + "context" + "fmt" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// GetClientCredentialsToken receives an OAuth2 Client Credentials +// configuration and returns a new token or an error if one occurs. +func GetClientCredentialsToken( + ctx context.Context, + clientID string, + clientSecret string, + scopes []string, + tokenEndpointURL string, + maxAttempts int, +) (*oauth2.Token, error) { + + oauth2Config := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenEndpointURL, + Scopes: scopes, + } + + var token *oauth2.Token + var result error + + // Attempt to retrieve token, retry up to maximum before giving up. + for attempt := 1; attempt <= maxAttempts; attempt++ { + token, result = oauth2Config.Token(ctx) + + switch { + + // Error encountered. Wait briefly before trying again. + case result != nil: + time.Sleep(1 * time.Second) + + // Token validity failed (for reasons unknown). Wait briefly before + // trying again. + case !token.Valid(): + time.Sleep(1 * time.Second) + + default: + // Successful retrieval, return token. + return token, nil + } + + } + + return nil, fmt.Errorf( + "failed to retrieve token: %w", + result, + ) + +}