git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / cmd / git-credential-cosmocr / creds.go /
cmd/git-credential-cosmocr/creds.go · 3.6 KiB · raw
view: Source ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// Command git-credential-cosmocr is a git credential helper that obtains a
// short-lived SSO token from the cosmocr.at vault (via a browser passkey login,
// or a device-code flow when there is no browser) and hands it to git as HTTP
// Basic-auth credentials for git.cosmocr.at.
//
// Install once:
//
//	git config --global credential.https://git.cosmocr.at.helper cosmocr
//
// (git resolves the helper name "cosmocr" to git-credential-cosmocr on PATH.)
package main

import (
	"bufio"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// parseAttrs reads the key=value credential attributes git writes on stdin,
// stopping at a blank line or EOF.
func parseAttrs(r io.Reader) map[string]string {
	attrs := map[string]string{}
	sc := bufio.NewScanner(r)
	for sc.Scan() {
		line := sc.Text()
		if line == "" {
			break
		}
		k, v, ok := strings.Cut(line, "=")
		if ok {
			attrs[k] = v
		}
	}
	return attrs
}

// writeAttrs writes credential attributes back to git.
func writeAttrs(w io.Writer, attrs map[string]string) {
	for _, k := range []string{"username", "password", "password_expiry_utc"} {
		if v, ok := attrs[k]; ok {
			fmt.Fprintf(w, "%s=%s\n", k, v)
		}
	}
}

// tokenInfo is the subset of an SSO token's claims the helper needs.
type tokenInfo struct {
	Sub string `json:"sub"`
	Exp int64  `json:"exp"`
}

// decodeToken extracts the subject and expiry from a "v1.<payload>.<r>.<s>"
// token without verifying the signature (verification is the server's job; the
// helper only needs the claims to set the username and cache expiry).
func decodeToken(token string) (tokenInfo, error) {
	parts := strings.Split(token, ".")
	if len(parts) != 4 || parts[0] != "v1" {
		return tokenInfo{}, fmt.Errorf("malformed token")
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return tokenInfo{}, err
	}
	var info tokenInfo
	if err := json.Unmarshal(payload, &info); err != nil {
		return tokenInfo{}, err
	}
	return info, nil
}

// expired reports whether the token is expired or within a small skew of it, so
// the helper refreshes slightly early rather than handing git a token that dies
// mid-operation.
func expired(token string, now time.Time) bool {
	info, err := decodeToken(token)
	if err != nil {
		return true
	}
	return now.Add(30*time.Second).Unix() >= info.Exp
}

// --- token cache (per host, mode 0600) ---

type cache struct {
	path string
}

type cacheFile struct {
	Tokens map[string]string `json:"tokens"` // host -> token
}

func defaultCachePath() string {
	dir := os.Getenv("XDG_CONFIG_HOME")
	if dir == "" {
		home, err := os.UserHomeDir()
		if err != nil {
			home = "."
		}
		dir = filepath.Join(home, ".config")
	}
	return filepath.Join(dir, "cosmocr", "tokens.json")
}

func (c cache) load() cacheFile {
	cf := cacheFile{Tokens: map[string]string{}}
	data, err := os.ReadFile(c.path)
	if err != nil {
		return cf
	}
	_ = json.Unmarshal(data, &cf)
	if cf.Tokens == nil {
		cf.Tokens = map[string]string{}
	}
	return cf
}

func (c cache) save(cf cacheFile) error {
	if err := os.MkdirAll(filepath.Dir(c.path), 0o700); err != nil {
		return err
	}
	data, err := json.Marshal(cf)
	if err != nil {
		return err
	}
	return os.WriteFile(c.path, data, 0o600)
}

func (c cache) get(host string) (string, bool) {
	tok, ok := c.load().Tokens[host]
	return tok, ok
}

func (c cache) set(host, token string) error {
	cf := c.load()
	cf.Tokens[host] = token
	return c.save(cf)
}

func (c cache) del(host string) error {
	cf := c.load()
	if _, ok := cf.Tokens[host]; !ok {
		return nil
	}
	delete(cf.Tokens, host)
	return c.save(cf)
}