git@cosmocr.at/gitweb.git · summary refs
Add browser SSO login for private repos (loosely coupled to issuer)
commit 742747e716ebf8130789597c257cbffdb7b70753
Patrick Gallagher <rpg137@gmail.com> · 2026-07-12 22:04 -0400
parent 44d91419
Add a passkey SSO "Sign in" flow: /_auth/login redirects to the issuer's
authorize page, the token returns in the callback URL fragment, and a
session cookie carries it. Tokens are verified statelessly against the
issuer's upspin keyserver key (no runtime call to the issuer). Anonymous
browser hits on private paths redirect to login; git/curl still get a
Basic-auth challenge. Issuer-agnostic flags: -sso_issuer, -sso_audience,
-sso_authorize_url, -base_url.
README.md
# gitweb
Serves git repositories stored in [upspin](https://upspin.io) over HTTPS at
`https://git.cosmocr.at`: a read-only browse UI **and** the git smart-HTTP
protocol for `clone`/`fetch`/`push` with stock `git` (no git extensions).
gitweb runs as one upspin user (`git@cosmocr.at`) and reads/writes repos on the
owner's behalf. All access control lives in upspin **Access files** — gitweb
never has its own permission database.
The URL path is the upspin path, so a bare repo at upspin path
`u@example.com/proj.git` is served at
`https://git.cosmocr.at/u@example.com/proj.git`.
## Public vs private
The repo's **governing Access file** (the nearest `Access` file at or above the
repo directory) decides visibility:
- **Public** — grants read to all (`read: all`). Anyone may browse and clone,
no credentials.
- **Private** — readable by `git@cosmocr.at` but not by all. Browsing and
cloning require authentication; pushing requires `write`.
Because gitweb reads as `git@cosmocr.at`, a private repo's Access file must
grant that user read (and, for push, `create,write,delete`) in addition to the
human collaborators.
Example Access files (in the repo directory, e.g. `u@example.com/proj.git/Access`):
```
# public, read-only to the world; owner keeps full control
read,list: all
*: u@example.com
```
```
# private; alice can push, bob can only read; the service can serve+write
*: u@example.com
read,list,create,write,delete: alice@example.com, git@cosmocr.at
read,list: bob@example.com
```
## Authenticating (clients)
Credentials go over HTTPS Basic auth. There are two ways to get them.
### Passkey login via the credential helper (recommended)
Install the helper and point git at it once:
```
# clone the repo somewhere:
# $ git clone https://git.cosmocr.at/git@cosmocr.at/gitweb.git $REPO && cd $REPO
$ make helper && cp bin/git-credential-cosmocr ~/bin/ # anywhere on $PATH
$ git config --global credential.https://git.cosmocr.at.helper cosmocr
```
Now `git clone https://git.cosmocr.at/u@example.com/proj.git` opens your browser
to `vault.cosmocr.at`, you approve with your passkey, and git receives a
short-lived token automatically. On a headless machine (no browser) the helper
prints a URL and code to approve from another device (device-code flow); force
it with `COSMOCR_DEVICE=1`.
The token is a `cosmocr.at` SSO token minted by vault and signed with vault's
upspin key; gitweb verifies it against the upspin keyserver. Tokens live ~12h
and are cached in `~/.config/cosmocr/tokens.json` (mode 0600).
### Personal access token (CI / automation)
For non-interactive use, generate a token and store its SHA-256 hash in your own
upspin tree:
```
tok=$(openssl rand -hex 32)
printf '%s laptop-ci\n' "$(printf %s "$tok" | sha256sum | cut -d' ' -f1)" \
| upspin put u@example.com/gitweb/tokens
# let the service read your token file
printf 'read: git@cosmocr.at\n' | upspin put u@example.com/gitweb/Access
echo "$tok" # use as the Basic-auth password
```
Then clone with the token as the password (username = your upspin user):
```
git clone https://u%40example.com:$tok@git.cosmocr.at/u@example.com/proj.git
```
Revoke by deleting the line from `u@example.com/gitweb/tokens` (effective within
~1 minute).
## Creating a repo
gitweb serves existing bare repos; it does not create them. Bootstrap one, then
push to it:
```
git init --bare proj.git
upspin mkdir u@example.com/proj.git
upspin cp -R proj.git/ u@example.com/proj.git/
printf '*: u@example.com\nread,list,create,write,delete: git@cosmocr.at\n' \
| upspin put u@example.com/proj.git/Access
git remote add origin https://git.cosmocr.at/u@example.com/proj.git
git push -u origin main
```
## Markdown & diagrams
READMEs and `.md` files are rendered (GitHub-flavored markdown, class-based
syntax highlighting, sanitized with bluemonday). Fenced ` ```mermaid ` blocks
are drawn client-side: the block becomes `<pre class="mermaid">` and a small
same-origin script (`web/mermaid-init.js`) lazy-loads a **vendored** mermaid
bundle (`web/mermaid.min.js`, no CDN) only on pages that contain a diagram, then
renders it with mermaid's `securityLevel: "strict"`.
This is why the Content-Security-Policy allows `script-src 'self'` (our own
scripts only — no `'unsafe-inline'`, so markdown-injected inline script still
cannot run) and `style-src 'self' 'unsafe-inline'` (mermaid injects diagram
styles at render time). See `docs/ARCHITECTURE.md`, whose diagrams render when
this file is browsed through gitweb itself.
## Limitations
- `git clone --depth N` (shallow) is not supported by the underlying go-git
server; clone fully.
- `fetch` re-sends the full history (the server ignores client "have" lines);
fine for clone, wasteful for incremental fetch.
- Push is not atomic across refs; each ref updates independently.
- A repo/push is capped at 256 MiB.
## Running
- `make run-dev` — local file store (`./devdata`), no upspin/TLS, for hacking.
- Production: `-upspin_config`, `-tls_cert`, `-tls_key`, listens on `:8446`.
- See `DEPLOY.md`. SSO verification is enabled by `-vault_issuer`
- (default `vault@cosmocr.at`); set it to `""` to accept only PATs.
+ See `DEPLOY.md`. SSO token verification is enabled by `-sso_issuer`
+ (default `vault@cosmocr.at`); set it to `""` to accept only PATs. The token's
+ signature is checked against the issuer's upspin keyserver public key, so
+ gitweb never calls the issuer at runtime.
+- Browser SSO ("Sign in" for private repos): additionally set `-sso_authorize_url`
+ (default `https://vault.cosmocr.at/authorize`) and `-base_url` (default
+ `https://git.cosmocr.at`). The browser is redirected there to log in with a
+ passkey; the issuer returns a token in the callback URL fragment, which gitweb
+ verifies and stores in an HttpOnly session cookie. The issuer must allow-list
+ `<base_url>/_auth/callback` (for vault: `-sso_redirect gitweb=…/_auth/callback`).
## Layout
- `internal/store` — read/write access to upspin (or a local dir in dev).
- `internal/gitrepo` — materialize bare repos into memory, cache, push write-back.
- `internal/githttp` — git smart-HTTP wire protocol over go-git sessions.
- `internal/auth` — Access-file authorization; PAT and vault-token verification.
- `internal/server` — routing, browse UI, git endpoints, auth gating.
- `cmd/git-credential-cosmocr` — the passkey/device git credential helper.
internal/server/auth.go
package server
import (
"net/http"
"strings"
"gitweb/internal/auth"
"gitweb/internal/store"
)
// authenticate identifies the caller from HTTP Basic auth. It returns the
// verified upspin user name, or "" for an anonymous request (no credentials).
//
// The second result is false when credentials were supplied but invalid; in
// that case a 401 challenge has already been written and the caller must stop.
//
// The Basic-auth password is either a vault-issued SSO bearer token (from the
// passkey login flow) or a personal access token. Vault tokens are self-
// describing, so the username field is ignored for them; for PATs the username
// is the upspin user whose token file is consulted.
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) {
+ // Browser session: a gitweb_session cookie carries an issuer-signed SSO
+ // token from the passkey login flow. It is verified statelessly (same as a
+ // Basic-auth token), so a browser never needs to send Basic credentials. An
+ // expired or tampered cookie is treated as anonymous — never a hard failure —
+ // and cleared, so the next request re-prompts login rather than 401-looping.
+ if s.vault != nil {
+ if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
+ if sub, err := s.vault.Verify(c.Value); err == nil {
+ return sub, true
+ }
+ clearCookie(w, sessionCookie)
+ }
+ }
+
user, pass, ok := r.BasicAuth()
if !ok || pass == "" {
return "", true // anonymous
}
if s.vault != nil && auth.LooksLikeVaultToken(pass) {
sub, err := s.vault.Verify(pass)
if err != nil {
s.challenge(w)
return "", false
}
return sub, true
}
// Personal access token: the username names the token file's owner.
if !store.ValidUser(user) {
s.challenge(w)
return "", false
}
valid, err := s.pat.Verify(user, pass)
if err != nil {
s.serverErr(w, err)
return "", false
}
if !valid {
s.challenge(w)
return "", false
}
return user, true
}
// parseUpspinPath splits a URL path "/user@domain/rest..." into the upspin user
// and a cleaned remainder beginning with "/". ok is false if the user element
// is missing or malformed.
func parseUpspinPath(urlPath string) (user, clean string, ok bool) {
raw := strings.TrimPrefix(urlPath, "/")
user, rest, _ := strings.Cut(raw, "/")
if !store.ValidUser(user) {
return "", "", false
}
return user, cleanRest(rest), true
}
internal/server/git.go
package server
import (
"bytes"
"errors"
"html/template"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
"gitweb/internal/gitrepo"
"gitweb/internal/render"
)
var gitTmpl = template.Must(template.ParseFS(tmplFS, "templates/git.html"))
const logLimit = 100
// serveGit handles a request that Locate resolved to a bare repo. repoPath is
// the upspin repo root (e.g. "u@d.com/proj.git"); rest is the remaining URL
// after it (e.g. "blob/main/src/x.go"). base is "/"+repoPath.
func (s *Server) serveGit(w http.ResponseWriter, r *http.Request, repoPath, rest string) {
repo, release, err := s.repos.Get(repoPath)
if err != nil {
s.storeErr(w, err)
return
}
defer release()
base := "/" + repoPath
rest = strings.Trim(rest, "/")
verb, rem, _ := strings.Cut(rest, "/")
switch verb {
case "":
- s.gitSummary(w, repo, repoPath, base)
+ s.gitSummary(w, r, repo, repoPath, base)
case "refs":
- s.gitRefs(w, repo, repoPath, base)
+ s.gitRefs(w, r, repo, repoPath, base)
case "tree":
- s.gitTree(w, repo, repoPath, base, rem)
+ s.gitTree(w, r, repo, repoPath, base, rem)
case "blob":
- s.gitBlob(w, repo, repoPath, base, rem, false, r.URL.Query())
+ s.gitBlob(w, r, repo, repoPath, base, rem, false, r.URL.Query())
case "raw":
- s.gitBlob(w, repo, repoPath, base, rem, true, nil)
+ s.gitBlob(w, r, repo, repoPath, base, rem, true, nil)
case "log":
- s.gitLog(w, repo, repoPath, base, rem)
+ s.gitLog(w, r, repo, repoPath, base, rem)
case "commit":
- s.gitCommit(w, repo, repoPath, base, rem)
+ s.gitCommit(w, r, repo, repoPath, base, rem)
default:
s.notFound(w)
}
}
// --- view models ---
type gitNav struct {
RepoName string
Base string
Ref string
LogURL string
}
func newNav(repoName, base, ref string) gitNav {
n := gitNav{RepoName: repoName, Base: base, Ref: ref}
if ref != "" {
n.LogURL = base + "/log/" + escapePath(ref)
}
return n
}
type treeEntry struct {
Name string
IsDir bool
URL string
Size string
}
type crumb struct {
Name string
URL string // empty for the last crumb
}
type refLink struct {
Name string
Short string
URL string
LogURL string
}
// --- summary ---
-func (s *Server) gitSummary(w http.ResponseWriter, repo *git.Repository, repoName, base string) {
+func (s *Server) gitSummary(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base string) {
hash, ref, _, err := gitrepo.ResolveRef(repo, "")
if err != nil {
s.notFound(w)
return
}
nav := newNav(repoName, base, ref)
tree, err := treeAt(repo, hash, "")
if err != nil {
s.serverErr(w, err)
return
}
entries := treeEntries(repo, tree, base, ref, "")
branches, tags, err := gitrepo.Refs(repo)
if err != nil {
s.serverErr(w, err)
return
}
var readme template.HTML
if name := findReadme(tree); name != "" {
if f, err := tree.File(name); err == nil {
if data, err := f.Contents(); err == nil {
if h, err := s.renderer.ToHTML([]byte(data)); err == nil {
readme = rebaseRepoHTML(template.HTML(h), base, ref, path.Dir(name))
}
}
}
}
- s.renderGit(w, repoName, "summary", struct {
+ s.renderGit(w, r, repoName, "summary", struct {
Nav gitNav
Branches []refLink
Tags []refLink
Entries []treeEntry
Readme template.HTML
}{
Nav: nav,
Branches: refLinks(branches, base, "tree"),
Tags: refLinks(tags, base, "tree"),
Entries: entries,
Readme: readme,
})
}
// --- tree ---
-func (s *Server) gitTree(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
+func (s *Server) gitTree(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) {
hash, ref, sub, err := gitrepo.ResolveRef(repo, rem)
if err != nil {
s.notFound(w)
return
}
tree, err := treeAt(repo, hash, sub)
if err != nil {
s.notFound(w)
return
}
nav := newNav(repoName, base, ref)
title := repoName
if sub != "" {
title = repoName + "/" + sub
}
- s.renderGit(w, repoName, "tree", struct {
+ s.renderGit(w, r, repoName, "tree", struct {
Nav gitNav
Title string
Crumbs []crumb
Entries []treeEntry
}{
Nav: nav,
Title: title,
Crumbs: buildCrumbs(base, ref, sub, true),
Entries: treeEntries(repo, tree, base, ref, sub),
})
}
// --- blob / raw ---
-func (s *Server) gitBlob(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) {
+func (s *Server) gitBlob(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) {
hash, ref, sub, err := gitrepo.ResolveRef(repo, rem)
if err != nil || sub == "" {
s.notFound(w)
return
}
if isAccessFile(path.Base(sub)) {
s.notFound(w) // never render an upspin ACL, blob or raw
return
}
tree, err := treeAt(repo, hash, "")
if err != nil {
s.notFound(w)
return
}
file, err := tree.File(sub)
if err != nil {
s.notFound(w)
return
}
data, err := file.Contents()
if err != nil {
s.serverErr(w, err)
return
}
content := []byte(data)
if raw {
ctype := http.DetectContentType(content)
w.Header().Set("Content-Type", ctype)
w.Write(content)
return
}
rawURL := base + "/raw/" + escapePath(ref) + "/" + escapePath(sub)
binary := render.IsBinary(content)
name := path.Base(sub)
force := q.Get("force") == "1"
selected := s.renderers.Select(name, content, q.Get("render"), force)
var body template.HTML
var choices []rendererChoice
var forceURL string
if !binary {
body, err = selected.Render(name, content)
if err != nil {
s.serverErr(w, err)
return
}
if selected.ID == "markdown" {
// Rewrite relative links/images so they resolve to the file's
// directory in the repo rather than the current URL.
body = rebaseRepoHTML(body, base, ref, path.Dir(sub))
}
blobURL := base + "/blob/" + escapePath(ref) + "/" + escapePath(sub)
choices, forceURL = renderControls(s.renderers, blobURL, name, content, selected.ID, force)
}
- s.renderGit(w, repoName, "blob", blobView{
+ s.renderGit(w, r, repoName, "blob", blobView{
Nav: newNav(repoName, base, ref),
Crumbs: buildCrumbs(base, ref, sub, true),
Path: sub,
Size: humanSize(file.Size),
RawURL: rawURL,
Binary: binary,
Body: body,
Renderers: choices,
Force: force,
ForceURL: forceURL,
})
}
// blobView is the file-view model, including the renderer control bar.
type blobView struct {
Nav gitNav
Crumbs []crumb
Path string
Size string
RawURL string
Binary bool
Body template.HTML
Renderers []rendererChoice
Force bool
ForceURL string
}
// rendererChoice is one selectable renderer link in the control bar.
type rendererChoice struct {
Label string
URL string
Active bool
}
// renderControls builds the renderer switch links and the force-toggle link for
// a blob. With force on, every registered renderer is offered; otherwise only
// the eligible ones. The default renderer's link carries no ?render= so its URL
// stays clean.
func renderControls(reg *render.Registry, blobURL, name string, content []byte, selectedID string, force bool) ([]rendererChoice, string) {
mods := reg.Eligible(name, content)
if force {
mods = reg.All()
}
defID := reg.Default().ID
choices := make([]rendererChoice, 0, len(mods))
for _, m := range mods {
choices = append(choices, rendererChoice{
Label: m.Label,
URL: blobURL + blobQuery(m.ID, defID, force),
Active: m.ID == selectedID,
})
}
var forceURL string
if force {
// Turning force off: keep the selection only if it stays eligible.
keep := selectedID
if _, ok := find(reg.Eligible(name, content), keep); !ok {
keep = defID
}
forceURL = blobURL + blobQuery(keep, defID, false)
} else {
forceURL = blobURL + blobQuery(selectedID, defID, true)
}
return choices, forceURL
}
// blobQuery builds the query string for a blob link with the given renderer and
// force state. The default renderer is encoded as no ?render= param.
func blobQuery(id, defID string, force bool) string {
v := url.Values{}
if id != "" && id != defID {
v.Set("render", id)
}
if force {
v.Set("force", "1")
}
if len(v) == 0 {
return ""
}
return "?" + v.Encode()
}
func find(mods []render.Module, id string) (render.Module, bool) {
for _, m := range mods {
if m.ID == id {
return m, true
}
}
return render.Module{}, false
}
// --- log ---
-func (s *Server) gitLog(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
+func (s *Server) gitLog(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) {
hash, ref, _, err := gitrepo.ResolveRef(repo, rem)
if err != nil {
s.notFound(w)
return
}
iter, err := repo.Log(&git.LogOptions{From: hash})
if err != nil {
s.serverErr(w, err)
return
}
type commitRow struct {
Subject, Author, When, Short, URL string
}
var commits []commitRow
_ = iter.ForEach(func(c *object.Commit) error {
if len(commits) >= logLimit {
return errStopIter
}
commits = append(commits, commitRow{
Subject: subjectOf(c.Message),
Author: c.Author.Name,
When: c.Author.When.Format("2006-01-02"),
Short: c.Hash.String()[:8],
URL: base + "/commit/" + c.Hash.String(),
})
return nil
})
- s.renderGit(w, repoName, "log", struct {
+ s.renderGit(w, r, repoName, "log", struct {
Nav gitNav
Commits interface{}
}{
Nav: newNav(repoName, base, ref),
Commits: commits,
})
}
// --- commit ---
-func (s *Server) gitCommit(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) {
+func (s *Server) gitCommit(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base, rem string) {
rem = strings.Trim(rem, "/")
h, err := repo.ResolveRevision(plumbing.Revision(rem))
if err != nil {
s.notFound(w)
return
}
c, err := repo.CommitObject(*h)
if err != nil {
s.notFound(w)
return
}
var fromTree *object.Tree
var parents []struct{ Short, URL string }
if c.NumParents() > 0 {
p, err := c.Parent(0)
if err == nil {
fromTree, _ = p.Tree()
parents = append(parents, struct{ Short, URL string }{
Short: p.Hash.String()[:8],
URL: base + "/commit/" + p.Hash.String(),
})
}
}
toTree, err := c.Tree()
if err != nil {
s.serverErr(w, err)
return
}
changes, err := object.DiffTree(fromTree, toTree)
if err != nil {
s.serverErr(w, err)
return
}
patch, err := changes.Patch()
if err != nil {
s.serverErr(w, err)
return
}
- s.renderGit(w, repoName, "commit", struct {
+ s.renderGit(w, r, repoName, "commit", struct {
Nav gitNav
Subject string
Body string
Hash string
Author string
When string
Parents []struct{ Short, URL string }
Diff template.HTML
}{
Nav: newNav(repoName, base, ""),
Subject: subjectOf(c.Message),
Body: bodyOf(c.Message),
Hash: c.Hash.String(),
Author: c.Author.Name + " <" + c.Author.Email + ">",
When: c.Author.When.Format("2006-01-02 15:04 -0700"),
Parents: parents,
Diff: render.Diff(patch),
})
}
// --- refs ---
-func (s *Server) gitRefs(w http.ResponseWriter, repo *git.Repository, repoName, base string) {
+func (s *Server) gitRefs(w http.ResponseWriter, r *http.Request, repo *git.Repository, repoName, base string) {
branches, tags, err := gitrepo.Refs(repo)
if err != nil {
s.serverErr(w, err)
return
}
- s.renderGit(w, repoName, "refs", struct {
+ s.renderGit(w, r, repoName, "refs", struct {
Nav gitNav
Branches []refLink
Tags []refLink
}{
Nav: newNav(repoName, base, ""),
Branches: refLinks(branches, base, "tree"),
Tags: refLinks(tags, base, "tree"),
})
}
// --- helpers ---
// errStopIter is a sentinel to stop a commit ForEach early.
var errStopIter = errors.New("stop")
-func (s *Server) renderGit(w http.ResponseWriter, repoName, name string, data interface{}) {
+func (s *Server) renderGit(w http.ResponseWriter, r *http.Request, repoName, name string, data interface{}) {
var body bytes.Buffer
if err := gitTmpl.ExecuteTemplate(&body, name, data); err != nil {
s.serverErr(w, err)
return
}
- s.renderPage(w, repoName, template.HTML(body.String()))
+ s.renderPage(w, r, repoName, template.HTML(body.String()))
}
// treeAt returns the tree at sub within the commit identified by hash.
func treeAt(repo *git.Repository, hash plumbing.Hash, sub string) (*object.Tree, error) {
c, err := repo.CommitObject(hash)
if err != nil {
return nil, err
}
tree, err := c.Tree()
if err != nil {
return nil, err
}
if sub == "" {
return tree, nil
}
return tree.Tree(sub)
}
func treeEntries(repo *git.Repository, tree *object.Tree, base, ref, sub string) []treeEntry {
out := make([]treeEntry, 0, len(tree.Entries))
for _, e := range tree.Entries {
if isAccessFile(e.Name) {
continue // don't list an upspin ACL committed into the tree
}
child := e.Name
if sub != "" {
child = sub + "/" + e.Name
}
isDir := e.Mode == filemode.Dir
te := treeEntry{Name: e.Name, IsDir: isDir}
if isDir {
te.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(child)
} else {
te.URL = base + "/blob/" + escapePath(ref) + "/" + escapePath(child)
if b, err := repo.BlobObject(e.Hash); err == nil {
te.Size = humanSize(b.Size)
}
}
out = append(out, te)
}
// Directories first, then by name.
sortTreeEntries(out)
return out
}
func sortTreeEntries(es []treeEntry) {
// Simple insertion sort keeps dirs first then name (lists are small).
for i := 1; i < len(es); i++ {
for j := i; j > 0 && less(es[j], es[j-1]); j-- {
es[j], es[j-1] = es[j-1], es[j]
}
}
}
func less(a, b treeEntry) bool {
if a.IsDir != b.IsDir {
return a.IsDir
}
return a.Name < b.Name
}
func buildCrumbs(base, ref, sub string, dirLinks bool) []crumb {
crumbs := []crumb{{Name: ref, URL: base + "/tree/" + escapePath(ref)}}
if sub == "" {
return crumbs
}
parts := strings.Split(sub, "/")
acc := ""
for i, p := range parts {
if acc == "" {
acc = p
} else {
acc = acc + "/" + p
}
c := crumb{Name: p}
last := i == len(parts)-1
if !last || dirLinks {
c.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(acc)
}
if last {
c.URL = "" // current location, not a link
}
crumbs = append(crumbs, c)
}
return crumbs
}
func refLinks(refs []gitrepo.RefName, base, verb string) []refLink {
out := make([]refLink, 0, len(refs))
for _, r := range refs {
out = append(out, refLink{
Name: r.Name,
Short: r.Hash.String()[:8],
URL: base + "/" + verb + "/" + escapePath(r.Name),
LogURL: base + "/log/" + escapePath(r.Name),
})
}
return out
}
func findReadme(tree *object.Tree) string {
for _, name := range []string{"README.md", "readme.md", "README.markdown", "README"} {
if _, err := tree.File(name); err == nil {
return name
}
}
return ""
}
// rebaseRepoHTML rewrites relative links/images in rendered repo markdown so
// they resolve to the source file's location in the repo (blob view for links,
// raw for images) at ref, given the markdown file's directory dir within the
// tree ("" or "." for the repo root).
func rebaseRepoHTML(h template.HTML, base, ref, dir string) template.HTML {
prefix := escapePath(ref) + "/"
if dir != "" && dir != "." {
prefix += escapePath(dir) + "/"
}
return render.Rebase(h, base+"/blob/"+prefix, base+"/raw/"+prefix)
}
// escapePath URL-escapes each slash-separated segment, preserving slashes.
func escapePath(p string) string {
parts := strings.Split(p, "/")
for i, s := range parts {
parts[i] = url.PathEscape(s)
}
return strings.Join(parts, "/")
}
func subjectOf(msg string) string {
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return strings.TrimSpace(msg[:i])
}
return strings.TrimSpace(msg)
}
func bodyOf(msg string) string {
if i := strings.IndexByte(msg, '\n'); i >= 0 {
return strings.TrimSpace(msg[i+1:])
}
return ""
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + string("KMGT"[exp]) + "iB"
}
internal/server/githttp.go
package server
import (
"log"
"net/http"
"path"
"strings"
"gitweb/internal/githttp"
"gitweb/internal/gitrepo"
)
// gitKind identifies which git smart-HTTP endpoint a request hit.
type gitKind int
const (
gitInfoRefs gitKind = iota
gitUploadPack
gitReceivePack
)
// gitReq describes a matched git smart-HTTP request.
type gitReq struct {
kind gitKind
service githttp.Service // the effective service (upload-pack or receive-pack)
urlPath string // URL path with the git suffix stripped, e.g. "/u@d.com/x.git"
}
// gitEndpoint recognizes the three git smart-HTTP endpoints by method and path
// suffix. It returns ok=false for ordinary browse requests.
func gitEndpoint(r *http.Request) (gitReq, bool) {
p := r.URL.Path
switch {
case r.Method == http.MethodGet && strings.HasSuffix(p, "/info/refs"):
svc := r.URL.Query().Get("service")
switch githttp.Service(svc) {
case githttp.UploadPack, githttp.ReceivePack:
return gitReq{gitInfoRefs, githttp.Service(svc), strings.TrimSuffix(p, "/info/refs")}, true
}
// info/refs without a smart service param: fall through to browse (this
// also lets someone view a repo's literal info/refs file).
return gitReq{}, false
case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-upload-pack"):
return gitReq{gitUploadPack, githttp.UploadPack, strings.TrimSuffix(p, "/git-upload-pack")}, true
case r.Method == http.MethodPost && strings.HasSuffix(p, "/git-receive-pack"):
return gitReq{gitReceivePack, githttp.ReceivePack, strings.TrimSuffix(p, "/git-receive-pack")}, true
}
return gitReq{}, false
}
// handleGitHTTP serves a git smart-HTTP request: authenticate, resolve the repo,
// authorize (read for clone/fetch, write for push), then run the protocol.
func (s *Server) handleGitHTTP(w http.ResponseWriter, r *http.Request, ep gitReq) {
user, clean, ok := parseUpspinPath(ep.urlPath)
if !ok {
s.notFound(w)
return
}
authUser, ok := s.authenticate(w, r)
if !ok {
return // 401 already written
}
repoPath, rest, isRepo, err := gitrepo.Locate(s.store, user, clean)
if err != nil {
s.storeErr(w, err)
return
}
// Endpoints live only at a repo root.
if !isRepo || rest != "" {
- s.denyRead(w, authUser)
+ s.denyRead(w, r, authUser)
return
}
writing := ep.service == githttp.ReceivePack
if writing {
// Push always requires an authenticated, authorized writer.
if authUser == "" {
s.challenge(w)
return
}
can, err := s.authz.CanWrite(authUser, repoPath)
if err != nil {
s.serverErr(w, err)
return
}
if !can {
// The caller is known; distinguish "can't read" (hide it) from
// "can read but not write" (explicit 403).
readable, rerr := s.authz.CanRead(authUser, repoPath)
if rerr != nil {
s.serverErr(w, rerr)
return
}
if !readable {
s.notFound(w)
return
}
http.Error(w, "403 forbidden: no write access", http.StatusForbidden)
return
}
} else {
can, err := s.authz.CanRead(authUser, repoPath)
if err != nil {
s.serverErr(w, err)
return
}
if !can {
- s.denyRead(w, authUser)
+ s.denyRead(w, r, authUser)
return
}
}
switch ep.kind {
case gitInfoRefs:
storer, release, err := s.repos.GetStorer(repoPath)
if err != nil {
s.storeErr(w, err)
return
}
defer release()
if err := githttp.InfoRefs(w, storer, ep.service); err != nil {
log.Printf("gitweb: info/refs %s: %v", repoPath, err)
}
case gitUploadPack:
// upload-pack streams the packfile from the storer for the whole
// response; the deferred release keeps the repo's on-disk backing
// alive until ServeUploadPack returns.
storer, release, err := s.repos.GetStorer(repoPath)
if err != nil {
s.storeErr(w, err)
return
}
defer release()
if err := githttp.ServeUploadPack(w, r, storer); err != nil {
log.Printf("gitweb: upload-pack %s: %v", repoPath, err)
}
case gitReceivePack:
req, err := githttp.DecodeReceivePack(r)
if err != nil {
http.Error(w, "400 bad request", http.StatusBadRequest)
return
}
rs, err := s.pusher.ReceivePack(r.Context(), repoPath, req)
if err != nil {
log.Printf("gitweb: receive-pack %s: %v", repoPath, err)
if rs == nil {
s.serverErr(w, err)
return
}
// rs carries per-ref failure detail; report it to the client.
}
if err := githttp.WriteReceivePackResult(w, rs); err != nil {
log.Printf("gitweb: receive-pack result %s: %v", repoPath, err)
}
}
}
// cleanRest cleans a URL remainder into an upspin path suffix beginning with
// "/". path.Clean collapses any ".." so it cannot escape the user's tree.
func cleanRest(rest string) string {
return path.Clean("/" + rest)
}
internal/server/server.go
// Package server is the HTTP layer of the git browser. The upspin path is the
// URL path: GET /<user@domain>/<path...>. If the path resolves inside a bare
// git repository the request is handled by the git browser (summary, tree,
// blob, log, commit, refs); otherwise it falls back to plain upspin namespace
// browsing (directory listings and raw files) so users can drill down to repos.
package server
import (
"bytes"
+ "context"
"embed"
"errors"
"html/template"
"io"
"log"
"mime"
"net/http"
+ "net/url"
"os"
"path"
"strings"
"gitweb/internal/auth"
"gitweb/internal/gitrepo"
"gitweb/internal/render"
"gitweb/internal/store"
)
//go:embed templates/*.html
var tmplFS embed.FS
var pageTmpl = template.Must(template.ParseFS(tmplFS, "templates/page.html"))
// Config configures a Server.
type Config struct {
Store store.WriteStore // read for browse/clone; write for push
Web http.FileSystem // static assets (style.css, landing index.html)
Realm string // HTTP Basic auth realm; defaults to "gitweb"
- // Vault, if non-nil, verifies vault-issued SSO bearer tokens (the passkey
+ // Vault, if non-nil, verifies issuer-signed SSO bearer tokens (the passkey
// login path). When nil, only PATs are accepted.
Vault *auth.VaultVerifier
+ // SSOAuthorizeURL is the issuer's browser login endpoint (e.g.
+ // https://vault.cosmocr.at/authorize). When set (with Vault non-nil), the
+ // browser "Sign in" flow at /_auth/login is enabled. Empty disables it.
+ SSOAuthorizeURL string
+ // SSOAudience is the audience requested in the browser SSO flow (e.g.
+ // "gitweb"); it must match the audience Vault accepts.
+ SSOAudience string
+ // BaseURL is this service's public origin (e.g. https://git.cosmocr.at),
+ // used to build the SSO redirect_uri. Not derived from the Host header, so a
+ // spoofed Host cannot redirect the token elsewhere.
+ BaseURL string
// CacheDir is where repos are materialized on disk. Its contents are
// deleted at startup. Empty means an ephemeral directory under the system
// temp dir (dev and tests).
CacheDir string
// CacheBudgetBytes and MaxRepoBytes bound the repo cache; 0 means the
// gitrepo defaults (512 MiB / 256 MiB).
CacheBudgetBytes int64
MaxRepoBytes int64
}
// Server serves upspin-stored git repositories over HTTP: a browse UI, plus the
// git smart-HTTP protocol for clone and push. Access is gated by upspin Access
// files; private repos require Basic-auth credentials (a vault SSO token or a
// personal access token).
type Server struct {
- store store.Store
- web http.FileSystem
- renderer *render.Renderer
- renderers *render.Registry
- repos *gitrepo.Cache
- authz *auth.Authorizer
- pat *auth.PATVerifier
- vault *auth.VaultVerifier // may be nil
- pusher *gitrepo.Pusher
- realm string
- chroma []byte // generated highlighter stylesheet
- landing []byte // /index.html, served at "/"
+ store store.Store
+ web http.FileSystem
+ renderer *render.Renderer
+ renderers *render.Registry
+ repos *gitrepo.Cache
+ authz *auth.Authorizer
+ pat *auth.PATVerifier
+ vault *auth.VaultVerifier // may be nil
+ pusher *gitrepo.Pusher
+ realm string
+ ssoAuthURL string // issuer login endpoint; "" disables browser SSO
+ ssoAud string // audience requested in the browser SSO flow
+ baseURL string // this service's public origin, for redirect_uri
+ chroma []byte // generated highlighter stylesheet
+ landing []byte // /index.html, served at "/"
}
// New builds a Server.
func New(c Config) (*Server, error) {
css, err := render.CSS()
if err != nil {
return nil, err
}
rnd := render.New()
cacheDir := c.CacheDir
if cacheDir == "" {
cacheDir, err = os.MkdirTemp("", "gitweb-cache-")
if err != nil {
return nil, err
}
}
repos, err := gitrepo.NewCache(c.Store, gitrepo.CacheOptions{
Dir: cacheDir,
BudgetBytes: c.CacheBudgetBytes,
MaxRepoBytes: c.MaxRepoBytes,
})
if err != nil {
return nil, err
}
realm := c.Realm
if realm == "" {
realm = "gitweb"
}
s := &Server{
- store: c.Store,
- web: c.Web,
- renderer: rnd,
- renderers: render.NewRegistry(rnd),
- repos: repos,
- authz: auth.NewAuthorizer(c.Store),
- pat: auth.NewPATVerifier(c.Store),
- vault: c.Vault,
- pusher: gitrepo.NewPusher(c.Store, repos),
- realm: realm,
- chroma: css,
+ store: c.Store,
+ web: c.Web,
+ renderer: rnd,
+ renderers: render.NewRegistry(rnd),
+ repos: repos,
+ authz: auth.NewAuthorizer(c.Store),
+ pat: auth.NewPATVerifier(c.Store),
+ vault: c.Vault,
+ pusher: gitrepo.NewPusher(c.Store, repos),
+ realm: realm,
+ chroma: css,
+ ssoAuthURL: c.SSOAuthorizeURL,
+ ssoAud: c.SSOAudience,
+ baseURL: strings.TrimRight(c.BaseURL, "/"),
}
if f, err := c.Web.Open("index.html"); err == nil {
s.landing, _ = io.ReadAll(f)
f.Close()
}
return s, nil
}
// Handler returns the HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /_assets/chroma.css", s.handleChromaCSS)
mux.Handle("GET /_assets/", http.StripPrefix("/_assets/", http.FileServer(s.web)))
+ // Browser SSO: /_auth/* cannot collide with an upspin path (users are
+ // user@domain). Enabled only when an issuer login endpoint is configured.
+ if s.ssoEnabled() {
+ mux.HandleFunc("GET /_auth/login", s.handleLogin)
+ mux.HandleFunc("GET /_auth/callback", s.handleCallback)
+ mux.HandleFunc("POST /_auth/session", s.handleSession)
+ mux.HandleFunc("GET /_auth/logout", s.handleLogout)
+ }
// The catch-all accepts every method: git smart-HTTP needs POST, and
// ServeMux cannot match the "/info/refs", "/git-upload-pack",
// "/git-receive-pack" suffixes, so handleRoot dispatches by suffix itself.
mux.HandleFunc("/", s.handleRoot)
return securityHeaders(mux)
}
// handleRoot dispatches between the git smart-HTTP endpoints and the browse UI.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if ep, ok := gitEndpoint(r); ok {
s.handleGitHTTP(w, r, ep)
return
}
if r.Method != http.MethodGet {
http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed)
return
}
s.handlePath(w, r)
}
func (s *Server) handleChromaCSS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(s.chroma)
}
// handlePath dispatches a request whose URL path is an upspin path.
func (s *Server) handlePath(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.URL.Path, "/")
if raw == "" {
s.serveLanding(w)
return
}
// First element is the upspin user; validate before building any path.
user, rest, _ := strings.Cut(raw, "/")
if !store.ValidUser(user) {
s.notFound(w)
return
}
// Clean the remainder; path.Clean collapses any ".." so it cannot escape
// the user's tree. clean always begins with "/".
clean := path.Clean("/" + rest)
upath := user + clean // e.g. "u@d.com/proj.git"; root is "u@d.com/"
// Identify the caller (anonymous if no/blank credentials) before deciding
// what they may see.
authUser, ok := s.authenticate(w, r)
if !ok {
return // 401 already written
}
+ // Carry the viewer identity so page rendering can show sign-in/out state.
+ r = r.WithContext(context.WithValue(r.Context(), viewerKey{}, authUser))
// If the path lies inside a bare git repo, hand off to the git browser;
// otherwise fall back to plain upspin namespace browsing.
repoPath, gitRest, isRepo, err := gitrepo.Locate(s.store, user, clean)
if err != nil {
s.storeErr(w, err)
return
}
// Gate the browse UI by the same public/private rule as clone: anonymous
// users see only world-readable paths; others must be granted read.
resource := upath
if isRepo {
resource = repoPath
}
allowed, err := s.authz.CanRead(authUser, resource)
if err != nil {
s.serverErr(w, err)
return
}
if !allowed {
- s.denyRead(w, authUser)
+ s.denyRead(w, r, authUser)
return
}
if isRepo {
s.serveGit(w, r, repoPath, gitRest)
return
}
isDir, err := s.store.Stat(upath)
if err != nil {
s.storeErr(w, err)
return
}
if isDir {
s.serveDir(w, r, user, clean, upath)
return
}
- s.serveFile(w, upath, clean)
+ s.serveFile(w, r, upath, clean)
}
// serveDir renders a directory: README.md/index.md if present, else a listing.
func (s *Server) serveDir(w http.ResponseWriter, r *http.Request, user, clean, upath string) {
// A trailing slash is required so relative links resolve against the dir.
if !strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
entries, err := s.store.List(upath)
if err != nil {
s.storeErr(w, err)
return
}
// Prefer an index document if the directory has one.
for _, name := range []string{"index.md", "README.md", "readme.md"} {
if hasEntry(entries, name) {
- s.serveFile(w, strings.TrimRight(upath, "/")+"/"+name, clean)
+ s.serveFile(w, r, strings.TrimRight(upath, "/")+"/"+name, clean)
return
}
}
var body bytes.Buffer
body.WriteString(`<h1>Index of `)
body.WriteString(template.HTMLEscapeString(displayPath(user, clean)))
body.WriteString("</h1>\n<ul class=\"dir\">\n")
if clean != "/" {
body.WriteString(`<li><a href="../">../</a></li>` + "\n")
}
for _, e := range entries {
if isAccessFile(e.Name) {
continue // never expose upspin ACLs in a listing
}
name := e.Name
if e.IsDir {
name += "/"
}
esc := template.HTMLEscapeString(name)
body.WriteString(`<li><a href="` + esc + `">` + esc + "</a></li>\n")
}
body.WriteString("</ul>\n")
- s.renderPage(w, displayPath(user, clean), template.HTML(body.String()))
+ s.renderPage(w, r, displayPath(user, clean), template.HTML(body.String()))
}
// serveFile renders a markdown file, or serves any other file raw.
-func (s *Server) serveFile(w http.ResponseWriter, upath, clean string) {
+func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, upath, clean string) {
// Never serve an upspin Access file's contents (its user/group list),
// even when requested directly by path.
if isAccessFile(path.Base(upath)) {
s.notFound(w)
return
}
data, err := s.store.Get(upath)
if err != nil {
s.storeErr(w, err)
return
}
if isMarkdown(upath) {
html, err := s.renderer.ToHTML(data)
if err != nil {
s.serverErr(w, err)
return
}
- s.renderPage(w, path.Base(clean), template.HTML(html))
+ s.renderPage(w, r, path.Base(clean), template.HTML(html))
return
}
// Raw asset (images referenced by markdown, etc.).
ctype := mime.TypeByExtension(path.Ext(upath))
if ctype == "" {
ctype = http.DetectContentType(data)
}
w.Header().Set("Content-Type", ctype)
w.Write(data)
}
-func (s *Server) renderPage(w http.ResponseWriter, title string, body template.HTML) {
+func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, title string, body template.HTML) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
err := pageTmpl.Execute(w, struct {
Title string
Body template.HTML
- }{Title: title, Body: body})
+ Nav template.HTML
+ }{Title: title, Body: body, Nav: s.navHTML(r)})
if err != nil {
log.Printf("gitweb: template: %v", err)
}
}
+// viewerKey stashes the authenticated viewer (or "") on the request context, so
+// renderPage can show sign-in state without every render path taking it as an
+// argument.
+type viewerKey struct{}
+
+// navHTML builds the account nav shown on every rendered page: sign-out when the
+// viewer is authenticated, sign-in when SSO is available, nothing otherwise.
+func (s *Server) navHTML(r *http.Request) template.HTML {
+ if r == nil {
+ return ""
+ }
+ viewer, _ := r.Context().Value(viewerKey{}).(string)
+ if viewer != "" {
+ return template.HTML(`Signed in as <strong>` + template.HTMLEscapeString(viewer) +
+ `</strong> &middot; <a href="/_auth/logout">Sign out</a>`)
+ }
+ if s.ssoEnabled() {
+ return template.HTML(`<a href="/_auth/login?next=` +
+ template.HTMLEscapeString(url.QueryEscape(r.URL.Path)) + `">Sign in</a>`)
+ }
+ return ""
+}
+
func (s *Server) serveLanding(w http.ResponseWriter) {
if s.landing == nil {
s.notFound(w)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(s.landing)
}
// --- errors ---
func (s *Server) storeErr(w http.ResponseWriter, err error) {
if errors.Is(err, store.ErrNotFound) {
s.notFound(w)
return
}
s.serverErr(w, err)
}
func (s *Server) notFound(w http.ResponseWriter) {
http.Error(w, "404 not found", http.StatusNotFound)
}
// denyRead responds to a caller who may not read the requested resource. An
-// anonymous caller gets a Basic-auth challenge (so a git/browser client can
-// retry with credentials); an already-authenticated caller gets a 404, which
-// does not reveal whether the resource exists.
-func (s *Server) denyRead(w http.ResponseWriter, authUser string) {
- if authUser == "" {
- s.challenge(w)
+// already-authenticated caller gets a 404, which does not reveal whether the
+// resource exists. An anonymous caller gets either a redirect into the browser
+// SSO flow (if this looks like a browser and SSO is enabled) or a Basic-auth
+// challenge (so a git/curl client can retry with credentials).
+func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, authUser string) {
+ if authUser != "" {
+ s.notFound(w)
+ return
+ }
+ if s.ssoEnabled() && wantsHTML(r) {
+ http.Redirect(w, r, "/_auth/login?next="+url.QueryEscape(r.URL.Path), http.StatusSeeOther)
return
}
- s.notFound(w)
+ s.challenge(w)
+}
+
+// wantsHTML reports whether the request looks like a browser navigation (as
+// opposed to git/curl), so we can redirect it to the login page rather than
+// issue a Basic-auth challenge it can't satisfy interactively.
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
}
// challenge writes a 401 with a Basic-auth challenge. The body is deliberately
// vague so anonymous callers cannot distinguish "private" from "absent".
func (s *Server) challenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="`+s.realm+`", charset="UTF-8"`)
http.Error(w, "not found, or authentication required", http.StatusUnauthorized)
}
func (s *Server) serverErr(w http.ResponseWriter, err error) {
log.Printf("gitweb: 500: %v", err)
http.Error(w, "500 internal server error", http.StatusInternalServerError)
}
// --- helpers ---
func isMarkdown(p string) bool {
switch strings.ToLower(path.Ext(p)) {
case ".md", ".markdown":
return true
}
return false
}
// isAccessFile reports whether name is an upspin Access file. gitweb never lists
// or renders these: their contents enumerate the upspin users and groups granted
// access, which must not be exposed to browsers.
func isAccessFile(name string) bool { return name == "Access" }
func hasEntry(entries []store.Entry, name string) bool {
for _, e := range entries {
if !e.IsDir && e.Name == name {
return true
}
}
return false
}
// displayPath renders the upspin path for a heading: "u@d.com/docs/".
func displayPath(user, clean string) string {
if clean == "/" {
return user + "/"
}
return user + clean
}
func securityHeaders(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
// script-src 'self': the only scripts are our own, same-origin mermaid
// files (mermaid-init.js and the vendored bundle). No 'unsafe-inline', so
// script injected via rendered markdown cannot execute. style-src allows
// 'unsafe-inline' because mermaid injects diagram styles at render time;
// this permits inline styles but not scripts. Markdown may embed external
// https images; everything else is same-origin.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
h.ServeHTTP(w, r)
})
}
added internal/server/sso.go
+package server
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Browser single-sign-on against an external issuer (vault). gitweb never calls
+// the issuer at runtime: it redirects the browser to the issuer's login page,
+// receives an issuer-signed SSO token back in the callback URL *fragment*, and
+// verifies it statelessly with the same VaultVerifier used for Basic-auth
+// tokens (the signature roots in the issuer's upspin keyserver key). The token
+// then lives in a session cookie.
+//
+// Flow: /_auth/login -> issuer /authorize -> /_auth/callback#token=…&state=…
+// -> (page JS) POST /_auth/session -> gitweb_session cookie set.
+
+const (
+ sessionCookie = "gitweb_session" // holds the verified SSO token
+ oauthCookie = "gitweb_oauth" // holds {state, next} across the redirect
+ oauthTTL = 10 * time.Minute
+ sessionTTL = 12 * time.Hour // matches the issuer token TTL; expiry is
+ // ultimately enforced by verifying the token, not by the cookie MaxAge.
+)
+
+// ssoEnabled reports whether the browser login flow is configured.
+func (s *Server) ssoEnabled() bool {
+ return s.vault != nil && s.ssoAuthURL != "" && s.baseURL != ""
+}
+
+// handleLogin starts the browser SSO flow: it remembers where to return to,
+// then redirects to the issuer's login page with our callback as redirect_uri.
+func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
+ next := sanitizeNext(r.URL.Query().Get("next"))
+ state := randToken()
+
+ // Remember state (CSRF) and the post-login destination in a short-lived,
+ // same-origin cookie. SameSite=Lax so it survives the top-level GET
+ // navigation back from the issuer.
+ setCookie(w, oauthCookie, url.Values{"state": {state}, "next": {next}}.Encode(), oauthTTL)
+
+ q := url.Values{
+ "redirect_uri": {s.baseURL + "/_auth/callback"},
+ "state": {state},
+ "aud": {s.ssoAud},
+ }
+ http.Redirect(w, r, s.ssoAuthURL+"?"+q.Encode(), http.StatusSeeOther)
+}
+
+// handleCallback serves the page that the issuer redirects back to. The SSO
+// token arrives in the URL fragment (never sent to a server), so a tiny
+// same-origin script reads it and POSTs it to /_auth/session. Served as HTML
+// referencing an external script because the CSP forbids inline scripts.
+func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(`<!doctype html>
+<html lang="en"><head><meta charset="utf-8"><title>Signing in…</title>
+<link rel="stylesheet" href="/_assets/style.css"></head>
+<body><main><p id="msg">Completing sign-in…</p></main>
+<script src="/_assets/auth-callback.js"></script></body></html>`))
+}
+
+// handleSession consumes the token the callback page extracted from the
+// fragment. It checks the CSRF state against the oauth cookie, verifies the
+// token, and on success installs the session cookie. Body: {"token","state"}.
+func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ State string `json:"state"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&body); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ c, err := r.Cookie(oauthCookie)
+ if err != nil {
+ http.Error(w, "no login in progress", http.StatusBadRequest)
+ return
+ }
+ saved, _ := url.ParseQuery(c.Value)
+ clearCookie(w, oauthCookie) // one-shot regardless of outcome
+ wantState := saved.Get("state")
+ if wantState == "" || subtle.ConstantTimeCompare([]byte(wantState), []byte(body.State)) != 1 {
+ http.Error(w, "state mismatch", http.StatusBadRequest)
+ return
+ }
+
+ if _, err := s.vault.Verify(body.Token); err != nil {
+ http.Error(w, "invalid token", http.StatusUnauthorized)
+ return
+ }
+ setCookie(w, sessionCookie, body.Token, sessionTTL)
+ writeJSON(w, map[string]string{"next": sanitizeNext(saved.Get("next"))})
+}
+
+// handleLogout clears the session cookie and returns to the site root. It is a
+// plain GET link: gitweb's CSP forbids form submissions (form-action 'none'),
+// and forcing a logout is not a meaningful attack.
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+ clearCookie(w, sessionCookie)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+// sanitizeNext confines a post-login redirect to a local path, so the flow
+// cannot be turned into an open redirect. Anything not a plain absolute path
+// (including protocol-relative "//host") falls back to the site root.
+func sanitizeNext(next string) string {
+ if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/"
+ }
+ return next
+}
+
+func randToken() string {
+ b := make([]byte, 16)
+ _, _ = rand.Read(b)
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func setCookie(w http.ResponseWriter, name, val string, ttl time.Duration) {
+ http.SetCookie(w, &http.Cookie{
+ Name: name, Value: val, Path: "/",
+ MaxAge: int(ttl.Seconds()), HttpOnly: true, Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func clearCookie(w http.ResponseWriter, name string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: name, Value: "", Path: "/", MaxAge: -1,
+ HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
+ })
+}
added internal/server/sso_test.go
+package server
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "gitweb/internal/auth"
+ "gitweb/internal/store"
+
+ "upspin.io/upspin"
+)
+
+// newSSOTestServer builds a Server with browser SSO enabled and returns a
+// function that mints issuer-signed tokens the server will accept.
+func newSSOTestServer(t *testing.T) (*Server, func(sub string, exp time.Time) string) {
+ t.Helper()
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub := upspin.PublicKey(fmt.Sprintf("p256\n%s\n%s\n", priv.X.String(), priv.Y.String()))
+ const issuer = "vault@cosmocr.at"
+ verifier := auth.NewVaultVerifier(issuer, "gitweb",
+ func(upspin.UserName) (upspin.PublicKey, error) { return pub, nil })
+
+ s, err := New(Config{
+ Store: store.NewLocalStore(t.TempDir()),
+ Web: http.FS(os.DirFS("../../web")),
+ CacheDir: t.TempDir(),
+ Vault: verifier,
+ SSOAuthorizeURL: "https://vault.cosmocr.at/authorize",
+ SSOAudience: "gitweb",
+ BaseURL: "https://git.cosmocr.at",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ mint := func(sub string, exp time.Time) string {
+ claims := map[string]any{
+ "iss": issuer, "sub": sub, "aud": "gitweb",
+ "iat": time.Now().Unix(), "exp": exp.Unix(),
+ }
+ payload, _ := json.Marshal(claims)
+ p := base64.RawURLEncoding.EncodeToString(payload)
+ hash := sha256.Sum256([]byte("v1." + p))
+ r, ss, err := ecdsa.Sign(rand.Reader, priv, hash[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return "v1." + p + "." +
+ base64.RawURLEncoding.EncodeToString(r.Bytes()) + "." +
+ base64.RawURLEncoding.EncodeToString(ss.Bytes())
+ }
+ return s, mint
+}
+
+// TestAuthenticateSessionCookie: a valid session cookie authenticates the
+// browser; an expired one is ignored (anonymous) and cleared.
+func TestAuthenticateSessionCookie(t *testing.T) {
+ s, mint := newSSOTestServer(t)
+
+ // Valid cookie -> authenticated subject.
+ r := httptest.NewRequest("GET", "/u@d.com/x", nil)
+ r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(time.Hour))})
+ w := httptest.NewRecorder()
+ user, ok := s.authenticate(w, r)
+ if !ok || user != "alice@d.com" {
+ t.Fatalf("authenticate = (%q, %v), want (alice@d.com, true)", user, ok)
+ }
+
+ // Expired cookie -> anonymous, and the stale cookie is cleared.
+ r = httptest.NewRequest("GET", "/u@d.com/x", nil)
+ r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(-time.Minute))})
+ w = httptest.NewRecorder()
+ user, ok = s.authenticate(w, r)
+ if !ok || user != "" {
+ t.Fatalf("expired cookie: authenticate = (%q, %v), want (\"\", true)", user, ok)
+ }
+ if !clearsCookie(w, sessionCookie) {
+ t.Errorf("expired cookie was not cleared: %v", w.Result().Cookies())
+ }
+}
+
+// TestHandleSession covers the callback token-exchange endpoint: state must
+// match the oauth cookie and the token must verify before a session is set.
+func TestHandleSession(t *testing.T) {
+ s, mint := newSSOTestServer(t)
+ token := mint("bob@d.com", time.Now().Add(time.Hour))
+
+ post := func(oauthState, bodyState, tok string) *httptest.ResponseRecorder {
+ body, _ := json.Marshal(map[string]string{"token": tok, "state": bodyState})
+ r := httptest.NewRequest("POST", "/_auth/session", strings.NewReader(string(body)))
+ r.AddCookie(&http.Cookie{Name: oauthCookie,
+ Value: url.Values{"state": {oauthState}, "next": {"/u@d.com/priv.git"}}.Encode()})
+ w := httptest.NewRecorder()
+ s.handleSession(w, r)
+ return w
+ }
+
+ // Happy path: matching state + valid token -> 200, session cookie, next.
+ w := post("s123", "s123", token)
+ if w.Code != 200 {
+ t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body)
+ }
+ if !setsCookie(w, sessionCookie, token) {
+ t.Errorf("session cookie not set to the token")
+ }
+ var out struct{ Next string }
+ json.Unmarshal(w.Body.Bytes(), &out)
+ if out.Next != "/u@d.com/priv.git" {
+ t.Errorf("next = %q, want /u@d.com/priv.git", out.Next)
+ }
+
+ // State mismatch -> 400, no session.
+ if w := post("s123", "WRONG", token); w.Code != 400 {
+ t.Errorf("state mismatch status = %d, want 400", w.Code)
+ }
+ // Invalid token -> 401, no session.
+ if w := post("s123", "s123", "v1.garbage.a.b"); w.Code != 401 {
+ t.Errorf("invalid token status = %d, want 401", w.Code)
+ }
+}
+
+// TestDenyReadRoutes: an anonymous browser is redirected to login, a git/curl
+// client gets a Basic-auth challenge, an authenticated caller gets 404.
+func TestDenyReadRoutes(t *testing.T) {
+ s, _ := newSSOTestServer(t)
+
+ // Browser (Accept: text/html), anonymous -> 303 to /_auth/login.
+ r := httptest.NewRequest("GET", "/u@d.com/priv.git", nil)
+ r.Header.Set("Accept", "text/html")
+ w := httptest.NewRecorder()
+ s.denyRead(w, r, "")
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("browser deny status = %d, want 303", w.Code)
+ }
+ if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/_auth/login?next=") {
+ t.Errorf("Location = %q, want /_auth/login?next=…", loc)
+ }
+
+ // git/curl (no text/html), anonymous -> 401 challenge.
+ r = httptest.NewRequest("GET", "/u@d.com/priv.git/info/refs", nil)
+ w = httptest.NewRecorder()
+ s.denyRead(w, r, "")
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("cli deny status = %d, want 401", w.Code)
+ }
+
+ // Authenticated but unauthorized -> 404 (does not reveal existence).
+ r = httptest.NewRequest("GET", "/u@d.com/priv.git", nil)
+ r.Header.Set("Accept", "text/html")
+ w = httptest.NewRecorder()
+ s.denyRead(w, r, "eve@d.com")
+ if w.Code != http.StatusNotFound {
+ t.Errorf("authed deny status = %d, want 404", w.Code)
+ }
+}
+
+func setsCookie(w *httptest.ResponseRecorder, name, val string) bool {
+ for _, c := range w.Result().Cookies() {
+ if c.Name == name && c.Value == val && c.MaxAge >= 0 {
+ return true
+ }
+ }
+ return false
+}
+
+func clearsCookie(w *httptest.ResponseRecorder, name string) bool {
+ for _, c := range w.Result().Cookies() {
+ if c.Name == name && c.MaxAge < 0 {
+ return true
+ }
+ }
+ return false
+}
internal/server/templates/page.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="stylesheet" href="/_assets/style.css">
<link rel="stylesheet" href="/_assets/chroma.css">
<script src="/_assets/mermaid-init.js" defer></script>
</head>
<body>
+{{if .Nav}}<nav class="account-nav">{{.Nav}}</nav>{{end}}
<main class="markdown-body">
{{.Body}}
</main>
</body>
</html>
main.go
// Command gitweb serves git repositories stored in upspin over HTTPS: a browse
// UI plus the git smart-HTTP protocol for clone and push. It runs as a single
// upspin user (git@cosmocr.at); a repo owner publishes a path by granting that
// user access in an upspin Access file. The upspin path is the URL path, so a
// repo lives at https://git.cosmocr.at/<user@domain>/<path-to-repo>.
//
// Public vs private is derived from the repo's governing Access file: a repo
// readable by all is public (browse and clone anonymously); otherwise callers
// must authenticate with HTTP Basic auth, supplying either a vault-issued SSO
// token (see cmd/git-credential-cosmocr) or a personal access token. Push
// requires write access in the Access file. See README.md.
package main
import (
"crypto/tls"
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"sync"
"time"
"gitweb/internal/auth"
"gitweb/internal/server"
"gitweb/internal/store"
"upspin.io/config"
"upspin.io/rpc"
"upspin.io/transports"
"upspin.io/upspin"
// Packers used by the upspin client when reading packed files.
_ "upspin.io/pack/ee"
_ "upspin.io/pack/eeintegrity"
_ "upspin.io/pack/plain"
)
//go:embed all:web
var embeddedWeb embed.FS
func main() {
var (
addr = flag.String("addr", ":8446", "listen address")
certFile = flag.String("tls_cert", "", "TLS certificate (PEM)")
keyFile = flag.String("tls_key", "", "TLS key (PEM)")
upspinConf = flag.String("upspin_config", "/gitweb/config", "upspin config file for the gitweb user")
webDir = flag.String("web", "", "serve static assets from this dir (default: embedded)")
devDir = flag.String("dev", "", "if set, read files from this local dir instead of upspin (dev only)")
- vaultIss = flag.String("vault_issuer", "vault@cosmocr.at", "upspin user that issues SSO tokens ('' disables passkey/vault auth)")
- vaultAud = flag.String("vault_audience", "gitweb", "expected audience of accepted vault SSO tokens")
+ ssoIss = flag.String("sso_issuer", "vault@cosmocr.at", "upspin user that issues SSO tokens; its keyserver public key is trusted ('' disables SSO auth)")
+ ssoAud = flag.String("sso_audience", "gitweb", "expected audience of accepted SSO tokens")
+ ssoAuthURL = flag.String("sso_authorize_url", "https://vault.cosmocr.at/authorize", "issuer's browser login endpoint; the browser SSO 'Sign in' flow is enabled when set")
+ baseURL = flag.String("base_url", "https://git.cosmocr.at", "this service's public base URL, used to build the SSO redirect_uri")
cacheDir = flag.String("cache_dir", "", "repo cache directory — ALL its contents are DELETED at startup (default: fresh dir under the system temp dir)")
cacheMB = flag.Int64("cache_budget_mb", 512, "target total size of the on-disk repo cache, in MiB")
maxRepoMB = flag.Int64("max_repo_mb", 256, "reject repos larger than this when materializing, in MiB")
)
flag.Parse()
var st store.WriteStore
var vault *auth.VaultVerifier
if *devDir != "" {
st = store.NewLocalStore(*devDir)
log.Printf("gitweb: DEV MODE — local file store at %s", *devDir)
} else {
cfg, err := config.FromFile(*upspinConf)
if err != nil {
log.Fatalf("gitweb: load upspin config: %v", err)
}
transports.Init(cfg)
st = store.NewUpspinStore(cfg)
- if *vaultIss != "" {
- // Verify vault SSO tokens by looking up the issuer's public key
- // through the upspin keyserver.
+ if *ssoIss != "" {
+ // Verify SSO tokens by looking up the issuer's public key through
+ // the upspin keyserver — no runtime call to the issuer service.
lookup := rpc.PublicUserKeyService(cfg)
- vault = auth.NewVaultVerifier(upspin.UserName(*vaultIss), *vaultAud,
+ vault = auth.NewVaultVerifier(upspin.UserName(*ssoIss), *ssoAud,
auth.KeyLookup(lookup))
- log.Printf("gitweb: accepting SSO tokens from %s (aud=%s)", *vaultIss, *vaultAud)
+ log.Printf("gitweb: accepting SSO tokens from %s (aud=%s)", *ssoIss, *ssoAud)
}
}
var webFS http.FileSystem
if *webDir != "" {
webFS = http.Dir(*webDir)
} else {
sub, err := fs.Sub(embeddedWeb, "web")
if err != nil {
log.Fatalf("gitweb: embed web: %v", err)
}
webFS = http.FS(sub)
}
srv, err := server.New(server.Config{
Store: st,
Web: webFS,
Vault: vault,
+ SSOAuthorizeURL: *ssoAuthURL,
+ SSOAudience: *ssoAud,
+ BaseURL: *baseURL,
CacheDir: *cacheDir,
CacheBudgetBytes: *cacheMB << 20,
MaxRepoBytes: *maxRepoMB << 20,
})
if err != nil {
log.Fatalf("gitweb: init server: %v", err)
}
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("gitweb: serving on %s", *addr)
if *certFile == "" || *keyFile == "" {
log.Fatal(httpSrv.ListenAndServe()) // dev only (e.g. localhost)
}
httpSrv.TLSConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: (&certReloader{certFile: *certFile, keyFile: *keyFile}).get,
}
log.Fatal(httpSrv.ListenAndServeTLS("", ""))
}
// certReloader serves a file-based TLS certificate, reloading it when the files
// change so certbot renewals are picked up without a restart. (Copied from the
// vault/markdown services, which share this deployment model.)
type certReloader struct {
certFile, keyFile string
mu sync.RWMutex
cert *tls.Certificate
modTime time.Time
}
func (r *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
mt := newest(r.certFile, r.keyFile)
r.mu.RLock()
cert, loaded := r.cert, r.modTime
r.mu.RUnlock()
if cert == nil || mt.After(loaded) {
c, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
if err != nil {
if cert != nil {
log.Printf("gitweb: cert reload failed, keeping previous: %v", err)
return cert, nil
}
return nil, err
}
r.mu.Lock()
r.cert, r.modTime = &c, mt
r.mu.Unlock()
cert = &c
}
return cert, nil
}
func newest(files ...string) time.Time {
var t time.Time
for _, f := range files {
if fi, err := os.Stat(f); err == nil && fi.ModTime().After(t) {
t = fi.ModTime()
}
}
return t
}
added web/auth-callback.js
+"use strict";
+
+// SSO callback shim. The issuer redirects here with the signed token in the URL
+// *fragment* (#token=…&state=…), which the browser never sends to a server. We
+// read it client-side and hand it to /_auth/session, which verifies it and sets
+// the HttpOnly session cookie. Then we navigate to the page the user wanted.
+
+(function () {
+ function fail(msg) {
+ var el = document.getElementById("msg");
+ if (el) el.textContent = msg;
+ }
+
+ var params = new URLSearchParams(location.hash.replace(/^#/, ""));
+ var token = params.get("token");
+ var state = params.get("state");
+ if (!token) {
+ fail("Sign-in failed: no token was returned.");
+ return;
+ }
+
+ fetch("/_auth/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "same-origin",
+ body: JSON.stringify({ token: token, state: state }),
+ })
+ .then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ })
+ .then(function (data) {
+ // Replace so the callback URL (with its fragment) leaves no history entry.
+ location.replace(data.next || "/");
+ })
+ .catch(function () {
+ fail("Sign-in failed. Please try again.");
+ });
+})();
web/style.css
:root {
--fg: #1f2328;
--muted: #59636e;
--link: #0969da;
--border: #d1d9e0;
--code-bg: #f6f8fa;
--add-bg: #e6ffec;
--add-border: #2da44e;
--del-bg: #ffebe9;
--del-border: #cf222e;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background: #fff;
}
.markdown-body {
max-width: 980px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
.markdown-body h1,
.markdown-body h2 {
border-bottom: 1px solid var(--border);
padding-bottom: .3em;
margin-top: 1.6em;
}
.markdown-body h1:first-child { margin-top: 0; }
.markdown-body a { color: var(--link); text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; }
.markdown-body img { max-width: 100%; }
.markdown-body code {
background: var(--code-bg);
padding: .2em .4em;
border-radius: 6px;
font-size: 85%;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.markdown-body pre {
background: var(--code-bg);
padding: 1rem;
border-radius: 6px;
overflow: auto;
}
.markdown-body pre code { background: none; padding: 0; font-size: 100%; }
.markdown-body blockquote {
margin: 0;
padding: 0 1em;
color: var(--muted);
border-left: .25em solid var(--border);
}
.markdown-body table { border-collapse: collapse; }
.markdown-body th,
.markdown-body td { border: 1px solid var(--border); padding: 6px 13px; }
.markdown-body ul.dir { list-style: none; padding-left: 0; }
.markdown-body ul.dir li { padding: .15em 0; }
.markdown-body ul.dir a {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
/* --- git views --- */
.mono, .git-nav, .tree, .log, .refs, .diff {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.git-nav {
font-size: 90%;
color: var(--muted);
padding-bottom: .8em;
margin-bottom: 1.2em;
border-bottom: 1px solid var(--border);
}
.git-nav a { margin-right: .9em; }
.crumbs { font-size: 90%; margin: .4em 0 1em; }
.hash { color: var(--muted); }
/* tree + log + refs tables */
.tree, .log, .refs { width: 100%; border-collapse: collapse; }
.tree td, .log td, .refs td {
border: none;
border-bottom: 1px solid var(--border);
padding: 5px 10px 5px 0;
vertical-align: top;
}
.tree td.name a { font-weight: 500; }
.tree td.size, .log td.when, .log td.who { color: var(--muted); white-space: nowrap; }
.log td.subject a { color: var(--fg); }
/* blob */
.blob-meta { color: var(--muted); font-size: 90%; margin-bottom: .6em; }
.blob-controls {
display: flex;
align-items: center;
gap: .4rem;
font-size: 85%;
margin-bottom: .6em;
}
.blob-controls .label { color: var(--muted); }
.blob-controls .renderer,
.blob-controls .force-toggle {
text-decoration: none;
color: var(--link);
border: 1px solid var(--border);
border-radius: 6px;
padding: .1rem .5rem;
}
.blob-controls .renderer.active {
color: var(--fg);
background: var(--code-bg);
font-weight: 600;
}
.blob-controls .force-toggle { margin-left: auto; color: var(--muted); }
.blob-controls .force-toggle.on { color: var(--fg); background: var(--code-bg); }
.blob {
background: var(--code-bg);
border-radius: 6px;
padding: 1rem;
overflow: auto;
}
.blob pre { background: none; margin: 0; padding: 0; }
/* commit + diff */
.commit-meta {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: .8rem 1rem;
margin-bottom: 1.4em;
}
.commit-meta .subject { font-weight: 600; }
.diff {
width: 100%;
border-collapse: collapse;
font-size: 85%;
overflow: auto;
display: block;
}
.diff td { border: none; padding: 0 10px; white-space: pre-wrap; }
.diff .file-head td {
background: var(--code-bg);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
font-weight: 600;
padding: 6px 10px;
}
.diff .hunk td { color: var(--muted); background: #f6f8fa; }
.diff .diff-add { background: var(--add-bg); }
.diff .diff-del { background: var(--del-bg); }
.diff .diff-ctx { background: none; }
+
+/* Account nav (SSO sign-in/out), top-right on every rendered page. */
+.account-nav {
+ font-size: 0.85em;
+ text-align: right;
+ color: var(--muted);
+ margin: 0 auto 1em;
+ max-width: 980px;
+}