// 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..." // 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) }