Skip to content

Commit

Permalink
Initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
pcolladosoto committed Jan 20, 2024
0 parents commit e20a651
Show file tree
Hide file tree
Showing 11 changed files with 766 additions and 0 deletions.
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Ignore hidden files except this .gitignore
.*
!.gitignore

# Ignore built binaries
utok
bin/

# Ignore Postman/Hoppscotch collections: they contain
# sensitive data (i.e. tokens)!
*.json
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# uTok: A microscopic OpenID connect client
uTok aims to be a micro ($\mu$) client for generating access tokens through OpenID Connect's
Device Authorization Flow.

As hinted by its default settings, `uTok`'s main target is the
[WLCG's main Indigo IAM instance](https://wlcg.cloud.cnaf.infn.it). You can find a bit
of documentation on its APIs and such [here](https://indigo-iam.github.io/v/current/).
Even though we haven't tested it, `utok` might work with other *issuers*: we didn't really
do anything 'special' for targetting Indigo IAM when it comes to token generation.

Bear in mind the official client for Indigo IAM is [`oidc-agent`](https://github.com/indigo-dc/oidc-agent),
but we found it a bit 'aggressive' in its pursue of `ssh-agent`'s behavior and, after a lot of digging,
we didn't manage to get it to work on newer macOS versions or on CentOS 7...

This client's interface is rather self explanatory: running `utok` with no arguments will show some
pointers to make use of `utok`.

## Installation
You can just download the latest build for your platform and place the binary anywhere on your `PATH`.

Uninstalling `utok` is a matter of removing that binary!

## Getting the first token
In order to get a token you first need to create a client:

$ utok cli create

This will create `~/.utok/client.json` containing the reply's contents. This reply will
also be shown on screen.

After that, you can generate tokens with:

$ utok token

This instructs `utok` to read the contents of `~/.utok/client.json` to then try to generate a
token. If none have been generated previously, the Device Authorization Flow will be triggered
so that you'll need to navigate to a particular URL and input a code: all these instructions
will be shown on screen. The generated token will be stored on `~/.utok/token.json`.

After generating the first token, `utok` will leverage the **refresh token** embedded in the
initial one to re-generate access tokens at will. However, this is completely transparent:
the user need only run `utok token`. Fresh tokens will be stored on `~/.utok/token_fresh.json`.

After a client is no longer needed, it can be deleted with:

$ utok cli delete

And... that's it really! Happy tokening!
171 changes: 171 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"syscall"

"github.com/spf13/cobra"
"github.com/zitadel/oidc/v3/pkg/client"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
)

const CLIENT_CONF string = "client.json"

var cliCmd = &cobra.Command{
Use: "cli",
Short: "Create or delete the OIDC client.",
}

var cliCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an OIDC client.",
Run: func(cmd *cobra.Command, args []string) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
defer stop()
createdClient, err := createClient(ctx)
if err != nil {
fmt.Printf("error creating the client: %v\n", err)
os.Exit(-1)
}

prettyClient, err := json.MarshalIndent(createdClient, "", " ")
if err != nil {
fmt.Printf("error encoding client: %v\n", err)
os.Exit(-1)
}

fmt.Printf("%s\n", prettyClient)
if err := writeFile(CLIENT_CONF, append(prettyClient, "\n"...)); err != nil {
fmt.Printf("error saving the client to %s: %v", CLIENT_CONF, err)
}

os.Exit(0)
},
}

var cliDeleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete the OIDC client.",
Run: func(cmd *cobra.Command, args []string) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
defer stop()
if err := deleteClient(ctx); err != nil {
fmt.Printf("error deleting the client: %v\n", err)
os.Exit(-1)
}
fmt.Printf("client deleted successfully!\n")

if err := removeFile(CLIENT_CONF); err != nil {
fmt.Printf("error removing the client configuration: %v\n", err)
}

if err := removeFile(TOKEN_CONF); err != nil {
fmt.Printf("error removing the original token: %v\n", err)
}

if err := removeFile(REFRESH_TOKEN_CONF); err != nil {
fmt.Printf("error removing the refreshed token: %v\n", err)
}

os.Exit(0)
},
}

func createClient(ctx context.Context) (Client, error) {
var createdClient Client

oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient)
if err != nil {
return createdClient, fmt.Errorf("error discovering endpoints: %v", err)
}

cliConf := baseClientConf

hostname, err := os.Hostname()
if err == nil {
clientName = clientName + ":" + hostname
}
cliConf.ClientName = clientName
cliConf.Contacts = strings.Split(clientContacts, ",")
cliConf.Scope = strings.Join(strings.Split(scopes, ","), " ")

encPayload, err := json.Marshal(cliConf)
if err != nil {
return createdClient, err
}

req, err := http.NewRequest("POST", oidcConfig.RegistrationEndpoint, bytes.NewBuffer(encPayload))
if err != nil {
fmt.Println(err)
return createdClient, fmt.Errorf("error creating request: %v", err)
}
req.Header.Add("Content-Type", "application/json")

res, err := httphelper.DefaultHTTPClient.Do(req)
if err != nil {
fmt.Println(err)
return createdClient, fmt.Errorf("error making the request: %v", err)
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
fmt.Println(err)
return createdClient, fmt.Errorf("error reading back the reply: %v", err)
}

if err := json.Unmarshal(body, &createdClient); err != nil {
return createdClient, fmt.Errorf("error decoding reply: %v [%s]", err, body)
}

return createdClient, nil
}

func deleteClient(ctx context.Context) error {
cliConfRaw, err := readFile(CLIENT_CONF)
if err != nil {
return fmt.Errorf("error reading client conf: %v", err)
}

var cli Client
if err := json.Unmarshal(cliConfRaw, &cli); err != nil {
return fmt.Errorf("error parsing client conf: %v", err)
}

oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient)
if err != nil {
return fmt.Errorf("error discovering endpoints: %v", err)
}

req, err := http.NewRequest("DELETE", fmt.Sprintf("%s/%s", oidcConfig.RegistrationEndpoint, cli.ClientId), nil)
if err != nil {
fmt.Println(err)
return fmt.Errorf("error creating request: %v", err)
}
req.Header.Add("Authorization", "Bearer "+cli.RegistrationAccessToken)

res, err := httphelper.DefaultHTTPClient.Do(req)
if err != nil {
fmt.Println(err)
return fmt.Errorf("error making the request: %v", err)
}
defer res.Body.Close()

if res.StatusCode != http.StatusNoContent {
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("couldn't open the reply's body: %v", err)
}
return fmt.Errorf("%s [%d]", body, res.StatusCode)
}

return nil
}
49 changes: 49 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"syscall"

"github.com/spf13/cobra"
"github.com/zitadel/oidc/v3/pkg/client"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
)

var configCmd = &cobra.Command{
Use: "config",
Short: "Show the issuer's OIDC config.",
Run: func(cmd *cobra.Command, args []string) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT)
defer stop()

oidcConf, err := getOIDCConfig(ctx)
if err != nil {
fmt.Printf("error getting the configuration: %v\n", err)
os.Exit(-1)
}

prettyConf, err := json.MarshalIndent(oidcConf, "", " ")
if err != nil {
fmt.Printf("error encoding the configuration: %v\n", err)
os.Exit(-1)
}

fmt.Printf("%s\n", prettyConf)

os.Exit(0)
},
}

func getOIDCConfig(ctx context.Context) (oidc.DiscoveryConfiguration, error) {
oidcConfig, err := client.Discover(ctx, issuer, httphelper.DefaultHTTPClient)
if err != nil {
return oidc.DiscoveryConfiguration{}, fmt.Errorf("error discovering endpoints: %v", err)
}

return *oidcConfig, nil
}
28 changes: 28 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import "log"

var (
issuer string
clientName string
clientContacts string
scopes string
forceRecreate bool
)

func init() {
// Disable Cobra's completions.
rootCmd.CompletionOptions.DisableDefaultCmd = true

// Configure the main logger
log.SetFlags(log.Lshortfile)

// Define global flags
rootCmd.PersistentFlags().StringVar(&issuer, "iss", "https://wlcg.cloud.cnaf.infn.it/", "The issuer URL.")
rootCmd.PersistentFlags().StringVar(&clientName, "cli-name", "uTok-cli", "OIDC client name.")
rootCmd.PersistentFlags().StringVar(&clientContacts, "cli-contacts", "foo@faa.com", "Comma-separated OIDC client contacts.")
rootCmd.PersistentFlags().StringVar(&scopes, "scopes",
"storage.read:/atlasdatadisk/SAM/,openid,offline_access", "Comma separated scopes to request.")

tokenCmd.PersistentFlags().BoolVar(&forceRecreate, "recreate", false, "Force token recreation even if one is present.")
}
31 changes: 31 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
module github.com/pcolladosoto/utok

go 1.21

require (
github.com/spf13/cobra v1.8.0
github.com/zitadel/oidc v1.13.5
github.com/zitadel/oidc/v3 v3.10.3
)

require (
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/gorilla/schema v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/zitadel/logging v0.5.0 // indirect
github.com/zitadel/schema v1.3.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
golang.org/x/oauth2 v0.16.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
)
Loading

0 comments on commit e20a651

Please sign in to comment.