Skip to content

Commit

Permalink
Add "Fetcher" tools for OAuth2 token retrieval
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
atc0005 committed Nov 28, 2022
1 parent 30f31d6 commit 0f602a3
Show file tree
Hide file tree
Showing 24 changed files with 903 additions and 65 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
210 changes: 178 additions & 32 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions cmd/check_imap_mailbox_oauth2/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func processAccount(
account.OAuth2Settings.ClientSecret,
account.OAuth2Settings.Scopes,
account.OAuth2Settings.TokenURL,
cfg.RetrievalAttempts(),
logger,
)
if loginErr != nil {
Expand Down
21 changes: 21 additions & 0 deletions cmd/fetch-token/doc.go
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions cmd/fetch-token/main.go
Original file line number Diff line number Diff line change
@@ -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")
}

}
53 changes: 53 additions & 0 deletions cmd/fetch-token/winres/winres.json
Original file line number Diff line number Diff line change
@@ -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": ""
}
}
}
}
}
}
5 changes: 5 additions & 0 deletions cmd/list-emails/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions cmd/read-token/doc.go
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions cmd/read-token/main.go
Original file line number Diff line number Diff line change
@@ -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")

}
Loading

0 comments on commit 0f602a3

Please sign in to comment.