feat: support user access tokens

Ayman Bagabas created

Users now can generate access tokens and use them to authenticate with
Soft Serve HTTP Git server. It supports basic username & password,
generated access tokens, and JWT tokens.

As of now there is no way the user can set a password. This will be
implemented in a separate PR.

Access tokens hashes are stored in the database along with an optional
expiry date.

Access tokens can be used as the Git user or password in a HTTP clone
URL e.g. `https://<token>@git.example.com/repo.git`

fix: lint errors

fix: ensure default branch on http push

fix: address carlos comments

Change summary

go.mod                                |   8 -
go.sum                                |  90 ----------------
server/backend/access_token.go        |  78 ++++++++++++++
server/backend/auth.go                |  11 +
server/backend/user.go                |  51 +++++++++
server/daemon/daemon.go               |   5 
server/proto/access_token.go          |  13 ++
server/proto/errors.go                |  10 +
server/proto/user.go                  |   2 
server/ssh/cmd/cmd.go                 |   1 
server/ssh/cmd/token.go               | 154 ++++++++++++++++++++++++++++
server/ssh/git.go                     |   2 
server/store/access_token.go          |  18 +++
server/store/database/access_token.go |  79 ++++++++++++++
server/store/database/database.go     |  12 +
server/store/database/user.go         |  11 ++
server/store/store.go                 |   1 
server/store/user.go                  |   1 
server/web/auth.go                    | 137 ++++++++++++++++++-------
server/web/git.go                     |  76 +++++++++-----
server/web/server.go                  |   1 
testscript/testdata/help.txtar        |   1 
22 files changed, 589 insertions(+), 173 deletions(-)

Detailed changes

go.mod 🔗

@@ -18,7 +18,9 @@ require (
 )
 
 require (
+	github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305
 	github.com/caarlos0/env/v8 v8.0.0
+	github.com/caarlos0/tablewriter v0.1.0
 	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
 	github.com/charmbracelet/keygen v0.4.3
 	github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
@@ -49,7 +51,6 @@ require (
 require (
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
-	github.com/avast/retry-go v3.0.0+incompatible // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
@@ -57,10 +58,7 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
-	github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
-	github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
 	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
-	github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
@@ -68,7 +66,6 @@ require (
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
-	github.com/leonelquinteros/gotext v1.5.2 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
@@ -81,7 +78,6 @@ require (
 	github.com/muesli/mango v0.1.0 // indirect
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
-	github.com/pkg/errors v0.9.1 // indirect
 	github.com/prometheus/client_model v0.3.0 // indirect
 	github.com/prometheus/common v0.42.0 // indirect
 	github.com/prometheus/procfs v0.10.1 // indirect

go.sum 🔗

@@ -1,16 +1,9 @@
-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
-github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
-github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/avast/retry-go v2.4.2+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
-github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
-github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
@@ -18,10 +11,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305 h1:vJpZ14MU1/YhqsAyMst/70MHqRgCkPsIwZNoSgTm2Dc=
+github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305/go.mod h1:mSkwb/eZEwOJJJ4tqAKiuhLIPe0e9+FKhlU0oMCpbf8=
 github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
 github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
 github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
 github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
+github.com/caarlos0/tablewriter v0.1.0 h1:HWwl/Zh3GKgVejSeG8lKHc28YBbI7bLRW2tgvxFF2DA=
+github.com/caarlos0/tablewriter v0.1.0/go.mod h1:oZ3/mQeP+SC5c1Dr6zv/6jCf0dfsUWq+PuwNw8l3ir0=
 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
@@ -36,8 +33,6 @@ github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgy
 github.com/charmbracelet/keygen v0.4.3/go.mod h1:4e4FT3HSdLU/u83RfJWvzJIaVb8aX4MxtDlfXwpDJaI=
 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
-github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
-github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU=
 github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
@@ -52,24 +47,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dpotapov/go-spnego v0.0.0-20210315154721-298b63a54430/go.mod h1:AVSs/gZKt1bOd2AhkhbS7Qh56Hv7klde22yXVbwYJhc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-github.com/git-lfs/git-lfs/v3 v3.3.0 h1:cbRy9akD9/hDD7BaVifyNkWkURwC8RSPLzX9+siS+OE=
-github.com/git-lfs/git-lfs/v3 v3.3.0/go.mod h1:5y2vfVQpxUmceMlraOmmaQ83pYptQYCvPl32ybO2IVw=
-github.com/git-lfs/gitobj/v2 v2.1.1 h1:tf/VU6zL1kxa3he+nf6FO/syX+LGkm6WGDsMpfuXV7Q=
-github.com/git-lfs/gitobj/v2 v2.1.1/go.mod h1:q6aqxl6Uu3gWsip5GEKpw+7459F97er8COmU45ncAxw=
-github.com/git-lfs/go-netrc v0.0.0-20210914205454-f0c862dd687a/go.mod h1:70O4NAtvWn1jW8V8V+OKrJJYcxDLTmIozfi2fmSz5SI=
-github.com/git-lfs/pktline v0.0.0-20210330133718-06e9096e2825/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
-github.com/git-lfs/wildmatch/v2 v2.0.1 h1:Ds+aobrV5bK0wStILUOn9irllPyf9qrFETbKzwzoER8=
-github.com/git-lfs/wildmatch/v2 v2.0.1/go.mod h1:EVqonpk9mXbREP3N8UkwoWdrF249uHpCUo5CPXY81gw=
-github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
 github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
@@ -92,46 +75,25 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
-github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
-github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
-github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
-github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
-github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
-github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
-github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
-github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
-github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
 github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
 github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
-github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
-github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/leonelquinteros/gotext v1.5.0/go.mod h1:OCiUVHuhP9LGFBQ1oAmdtNCHJCiHiQA8lf4nAifHkr0=
-github.com/leonelquinteros/gotext v1.5.2 h1:T2y6ebHli+rMBCjcJlHTXyUrgXqsKBhl/ormgvt7lPo=
-github.com/leonelquinteros/gotext v1.5.2/go.mod h1:AT4NpQrOmyj1L/+hLja6aR0lk81yYYL4ePnj2kp7d6M=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -141,7 +103,6 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
 github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
@@ -159,8 +120,6 @@ github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX
 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
 github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
 github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
-github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -178,12 +137,8 @@ github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB
 github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
-github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0=
-github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
-github.com/pkg/errors v0.0.0-20170505043639-c605e284fe17/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -215,13 +170,10 @@ github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
 github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/ssgelm/cookiejarparser v1.0.1/go.mod h1:DUfC0mpjIzlDN7DzKjXpHj0qMI5m9VrZuz3wSlI+OEI=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -233,12 +185,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v0.0.0-20170210233622-6b67b3fab74d/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
 github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
@@ -249,44 +196,25 @@ goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c=
 goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
 golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
-golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
 golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
 golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -300,22 +228,14 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
 golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
 golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20200221224223-e1da425f72fd/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
 golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
@@ -323,12 +243,10 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

server/backend/access_token.go 🔗

@@ -0,0 +1,78 @@
+package backend
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/proto"
+)
+
+// CreateAccessToken creates an access token for user.
+func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) {
+	token := GenerateToken()
+	tokenHash := HashToken(token)
+
+	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		_, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		return nil
+	}); err != nil {
+		return "", err
+	}
+
+	return token, nil
+}
+
+// DeleteAccessToken deletes an access token for a user.
+func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error {
+	err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		_, err := b.store.GetAccessToken(ctx, tx, id)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil {
+			return db.WrapError(err)
+		}
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return proto.ErrTokenNotFound
+		}
+		return err
+	}
+
+	return nil
+}
+
+// ListAccessTokens lists access tokens for a user.
+func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) {
+	accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID())
+	if err != nil {
+		return nil, db.WrapError(err)
+	}
+
+	var tokens []proto.AccessToken
+	for _, t := range accessTokens {
+		token := proto.AccessToken{
+			ID:        t.ID,
+			Name:      t.Name,
+			TokenHash: t.Token,
+			UserID:    t.UserID,
+			CreatedAt: t.CreatedAt,
+		}
+		if t.ExpiresAt.Valid {
+			token.ExpiresAt = t.ExpiresAt.Time
+		}
+
+		tokens = append(tokens, token)
+	}
+
+	return tokens, nil
+}

server/backend/auth.go 🔗

@@ -2,6 +2,7 @@ package backend
 
 import (
 	"crypto/rand"
+	"crypto/sha256"
 	"encoding/hex"
 
 	"github.com/charmbracelet/log"
@@ -26,8 +27,8 @@ func VerifyPassword(password, hash string) bool {
 	return err == nil
 }
 
-// GenerateAccessToken returns a random unique token.
-func GenerateAccessToken() string {
+// GenerateToken returns a random unique token.
+func GenerateToken() string {
 	buf := make([]byte, 20)
 	if _, err := rand.Read(buf); err != nil {
 		log.Error("unable to generate access token")
@@ -36,3 +37,9 @@ func GenerateAccessToken() string {
 
 	return "ss_" + hex.EncodeToString(buf)
 }
+
+// HashToken hashes the token using sha256.
+func HashToken(token string) string {
+	sum := sha256.Sum256([]byte(token + saltySalt))
+	return hex.EncodeToString(sum[:])
+}

server/backend/user.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"strings"
+	"time"
 
 	"github.com/charmbracelet/soft-serve/server/access"
 	"github.com/charmbracelet/soft-serve/server/db"
@@ -117,6 +118,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 		if errors.Is(err, db.ErrRecordNotFound) {
 			return nil, proto.ErrUserNotFound
 		}
+		d.logger.Error("error finding user", "username", username, "error", err)
 		return nil, err
 	}
 
@@ -146,6 +148,46 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 		if errors.Is(err, db.ErrRecordNotFound) {
 			return nil, proto.ErrUserNotFound
 		}
+		d.logger.Error("error finding user", "pk", sshutils.MarshalAuthorizedKey(pk), "error", err)
+		return nil, err
+	}
+
+	return &user{
+		user:       m,
+		publicKeys: pks,
+	}, nil
+}
+
+// UserByAccessToken finds a user by access token.
+// This also validates the token for expiration and returns proto.ErrTokenExpired.
+func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) {
+	var m models.User
+	var pks []ssh.PublicKey
+	token = HashToken(token)
+
+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		t, err := d.store.GetAccessTokenByToken(ctx, tx, token)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) {
+			return proto.ErrTokenExpired
+		}
+
+		m, err = d.store.FindUserByAccessToken(ctx, tx, token)
+		if err != nil {
+			return db.WrapError(err)
+		}
+
+		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
+		return err
+	}); err != nil {
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, proto.ErrUserNotFound
+		}
+		d.logger.Error("failed to find user by access token", "err", err, "token", token)
 		return nil, err
 	}
 
@@ -335,3 +377,12 @@ func (u *user) Username() string {
 func (u *user) ID() int64 {
 	return u.user.ID
 }
+
+// Password implements proto.User.
+func (u *user) Password() string {
+	if u.user.Password.Valid {
+		return u.user.Password.String
+	}
+
+	return ""
+}

server/daemon/daemon.go 🔗

@@ -181,15 +181,12 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 			return
 		}
 
-		var handler git.ServiceHandler
 		var counter *prometheus.CounterVec
 		service := git.Service(split[0])
 		switch service {
 		case git.UploadPackService:
-			handler = git.UploadPack
 			counter = uploadPackGitCounter
 		case git.UploadArchiveService:
-			handler = git.UploadArchive
 			counter = uploadArchiveGitCounter
 		default:
 			d.fatal(c, git.ErrInvalidRequest)
@@ -289,7 +286,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
 			Dir:    filepath.Join(reposDir, repo),
 		}
 
-		if err := handler(ctx, cmd); err != nil {
+		if err := service.Handler(ctx, cmd); err != nil {
 			d.logger.Debugf("git: error handling request: %v", err)
 			d.fatal(c, err)
 			return

server/proto/access_token.go 🔗

@@ -0,0 +1,13 @@
+package proto
+
+import "time"
+
+// AccessToken represents an access token.
+type AccessToken struct {
+	ID        int64
+	Name      string
+	UserID    int64
+	TokenHash string
+	ExpiresAt time.Time
+	CreatedAt time.Time
+}

server/proto/errors.go 🔗

@@ -9,10 +9,14 @@ var (
 	ErrUnauthorized = errors.New("unauthorized")
 	// ErrFileNotFound is returned when the file is not found.
 	ErrFileNotFound = errors.New("file not found")
-	// ErrRepoNotFound is returned when a repository does not exist.
+	// ErrRepoNotFound is returned when a repository is not found.
 	ErrRepoNotFound = errors.New("repository not found")
 	// ErrRepoExist is returned when a repository already exists.
 	ErrRepoExist = errors.New("repository already exists")
-	// ErrUserNotFound is returned when a user does not exist.
-	ErrUserNotFound = errors.New("user does not exist")
+	// ErrUserNotFound is returned when a user is not found.
+	ErrUserNotFound = errors.New("user not found")
+	// ErrTokenNotFound is returned when a token is not found.
+	ErrTokenNotFound = errors.New("token not found")
+	// ErrTokenExpired is returned when a token is expired.
+	ErrTokenExpired = errors.New("token expired")
 )

server/proto/user.go 🔗

@@ -12,6 +12,8 @@ type User interface {
 	IsAdmin() bool
 	// PublicKeys returns the user's public keys.
 	PublicKeys() []ssh.PublicKey
+	// Password returns the user's password hash.
+	Password() string
 }
 
 // UserOptions are options for creating a user.

server/ssh/cmd/cmd.go 🔗

@@ -154,6 +154,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
 			pubkeyCommand(),
 			setUsernameCommand(),
 			jwtCommand(),
+			tokenCommand(),
 		)
 	}
 

server/ssh/cmd/token.go 🔗

@@ -0,0 +1,154 @@
+package cmd
+
+import (
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/caarlos0/duration"
+	"github.com/caarlos0/tablewriter"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/dustin/go-humanize"
+	"github.com/spf13/cobra"
+)
+
+func tokenCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:     "token",
+		Aliases: []string{"access-token"},
+		Short:   "Manage access tokens",
+	}
+
+	var createExpiresIn string
+	createCmd := &cobra.Command{
+		Use:   "create NAME",
+		Short: "Create a new access token",
+		Args:  cobra.MinimumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			name := strings.Join(args, " ")
+
+			user := proto.UserFromContext(ctx)
+			if user == nil {
+				return proto.ErrUserNotFound
+			}
+
+			var expiresAt time.Time
+			var expiresIn time.Duration
+			if createExpiresIn != "" {
+				d, err := duration.Parse(createExpiresIn)
+				if err != nil {
+					return err
+				}
+
+				expiresIn = d
+				expiresAt = time.Now().Add(d)
+			}
+
+			token, err := be.CreateAccessToken(ctx, user, name, expiresAt)
+			if err != nil {
+				return err
+			}
+
+			notice := "Access token created"
+			if expiresIn != 0 {
+				notice += " (expires in " + humanize.Time(expiresAt) + ")"
+			}
+
+			cmd.PrintErrln(notice)
+			cmd.Println(token)
+
+			return nil
+		},
+	}
+
+	createCmd.Flags().StringVar(&createExpiresIn, "expires-in", "", "Token expiration time (e.g. 1y, 3mo, 2w, 5d4h, 1h30m)")
+
+	listCmd := &cobra.Command{
+		Use:     "list",
+		Aliases: []string{"ls"},
+		Short:   "List access tokens",
+		Args:    cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+
+			user := proto.UserFromContext(ctx)
+			if user == nil {
+				return proto.ErrUserNotFound
+			}
+
+			tokens, err := be.ListAccessTokens(ctx, user)
+			if err != nil {
+				return err
+			}
+
+			if len(tokens) == 0 {
+				cmd.Println("No tokens found")
+				return nil
+			}
+
+			now := time.Now()
+			return tablewriter.Render(
+				cmd.OutOrStdout(),
+				tokens,
+				[]string{"ID", "Name", "Created At", "Expires In"},
+				func(t proto.AccessToken) ([]string, error) {
+					expiresAt := "-"
+					if !t.ExpiresAt.IsZero() {
+						if now.After(t.ExpiresAt) {
+							expiresAt = "expired"
+						} else {
+							expiresAt = humanize.Time(t.ExpiresAt)
+						}
+					}
+
+					return []string{
+						strconv.FormatInt(t.ID, 10),
+						t.Name,
+						humanize.Time(t.CreatedAt),
+						expiresAt,
+					}, nil
+				},
+			)
+		},
+	}
+
+	deleteCmd := &cobra.Command{
+		Use:     "delete ID",
+		Aliases: []string{"rm", "remove"},
+		Short:   "Delete an access token",
+		Args:    cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+
+			user := proto.UserFromContext(ctx)
+			if user == nil {
+				return proto.ErrUserNotFound
+			}
+
+			id, err := strconv.ParseInt(args[0], 10, 64)
+			if err != nil {
+				return err
+			}
+
+			if err := be.DeleteAccessToken(ctx, user, id); err != nil {
+				return err
+			}
+
+			cmd.PrintErrln("Access token deleted")
+			return nil
+		},
+	}
+
+	cmd.AddCommand(
+		createCmd,
+		listCmd,
+		deleteCmd,
+	)
+
+	return cmd
+}

server/ssh/git.go 🔗

@@ -93,7 +93,7 @@ func handleGit(s ssh.Session) {
 			createRepoCounter.WithLabelValues(name).Inc()
 		}
 
-		if err := git.ReceivePack(ctx, cmd); err != nil {
+		if err := service.Handler(ctx, cmd); err != nil {
 			sshFatal(s, git.ErrSystemMalfunction)
 		}
 

server/store/access_token.go 🔗

@@ -1 +1,19 @@
 package store
+
+import (
+	"context"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+)
+
+// AccessTokenStore is an interface for managing access tokens.
+type AccessTokenStore interface {
+	GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error)
+	GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error)
+	GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error)
+	CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error)
+	DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error
+	DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error
+}

server/store/database/access_token.go 🔗

@@ -0,0 +1,79 @@
+package database
+
+import (
+	"context"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/store"
+)
+
+type accessTokenStore struct{}
+
+var _ store.AccessTokenStore = (*accessTokenStore)(nil)
+
+// CreateAccessToken implements store.AccessTokenStore.
+func (s *accessTokenStore) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) {
+	queryWithoutExpires := `INSERT INTO access_tokens (name, user_id, token, created_at, updated_at)
+	VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
+	queryWithExpires := `INSERT INTO access_tokens (name, user_id, token, expires_at, created_at, updated_at)
+	VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
+
+	query := queryWithoutExpires
+	values := []interface{}{name, userID, token}
+	if !expiresAt.IsZero() {
+		query = queryWithExpires
+		values = append(values, expiresAt)
+	}
+
+	result, err := h.ExecContext(ctx, query, values...)
+	if err != nil {
+		return models.AccessToken{}, err
+	}
+
+	id, err := result.LastInsertId()
+	if err != nil {
+		return models.AccessToken{}, err
+	}
+
+	return s.GetAccessToken(ctx, h, id)
+}
+
+// DeleteAccessToken implements store.AccessTokenStore.
+func (*accessTokenStore) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error {
+	query := h.Rebind(`DELETE FROM access_tokens WHERE id = ?`)
+	_, err := h.ExecContext(ctx, query, id)
+	return err
+}
+
+// DeleteAccessTokenForUser implements store.AccessTokenStore.
+func (*accessTokenStore) DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error {
+	query := h.Rebind(`DELETE FROM access_tokens WHERE user_id = ? AND id = ?`)
+	_, err := h.ExecContext(ctx, query, userID, id)
+	return err
+}
+
+// GetAccessToken implements store.AccessTokenStore.
+func (*accessTokenStore) GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) {
+	query := h.Rebind(`SELECT * FROM access_tokens WHERE id = ?`)
+	var m models.AccessToken
+	err := h.GetContext(ctx, &m, query, id)
+	return m, err
+}
+
+// GetAccessTokensByUserID implements store.AccessTokenStore.
+func (*accessTokenStore) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) {
+	query := h.Rebind(`SELECT * FROM access_tokens WHERE user_id = ?`)
+	var m []models.AccessToken
+	err := h.SelectContext(ctx, &m, query, userID)
+	return m, err
+}
+
+// GetAccessTokenByToken implements store.AccessTokenStore.
+func (*accessTokenStore) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) {
+	query := h.Rebind(`SELECT * FROM access_tokens WHERE token = ?`)
+	var m models.AccessToken
+	err := h.GetContext(ctx, &m, query, token)
+	return m, err
+}

server/store/database/database.go 🔗

@@ -20,6 +20,7 @@ type datastore struct {
 	*userStore
 	*collabStore
 	*lfsStore
+	*accessTokenStore
 }
 
 // New returns a new store.Store database.
@@ -33,11 +34,12 @@ func New(ctx context.Context, db *db.DB) store.Store {
 		db:     db,
 		logger: logger,
 
-		settingsStore: &settingsStore{},
-		repoStore:     &repoStore{},
-		userStore:     &userStore{},
-		collabStore:   &collabStore{},
-		lfsStore:      &lfsStore{},
+		settingsStore:    &settingsStore{},
+		repoStore:        &repoStore{},
+		userStore:        &userStore{},
+		collabStore:      &collabStore{},
+		lfsStore:         &lfsStore{},
+		accessTokenStore: &accessTokenStore{},
 	}
 
 	return s

server/store/database/user.go 🔗

@@ -112,6 +112,17 @@ func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, usernam
 	return m, err
 }
 
+// FindUserByAccessToken implements store.UserStore.
+func (*userStore) FindUserByAccessToken(ctx context.Context, tx db.Handler, token string) (models.User, error) {
+	var m models.User
+	query := tx.Rebind(`SELECT users.*
+			FROM users
+			INNER JOIN access_tokens ON users.id = access_tokens.user_id
+			WHERE access_tokens.token = ?;`)
+	err := tx.GetContext(ctx, &m, query, token)
+	return m, err
+}
+
 // GetAllUsers implements store.UserStore.
 func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) {
 	var ms []models.User

server/store/store.go 🔗

@@ -7,4 +7,5 @@ type Store interface {
 	CollaboratorStore
 	SettingStore
 	LFSStore
+	AccessTokenStore
 }

server/store/user.go 🔗

@@ -13,6 +13,7 @@ type UserStore interface {
 	GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
 	FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
 	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
+	FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error)
 	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
 	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
 	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error

server/web/auth.go 🔗

@@ -16,62 +16,121 @@ import (
 
 // authenticate authenticates the user from the request.
 func authenticate(r *http.Request) (proto.User, error) {
-	ctx := r.Context()
+	// Prefer the Authorization header
+	user, err := parseAuthHdr(r)
+	if err != nil || user == nil {
+		return nil, proto.ErrUserNotFound
+	}
+
+	return user, nil
+}
+
+// ErrInvalidPassword is returned when the password is invalid.
+var ErrInvalidPassword = errors.New("invalid password")
+
+func parseUsernamePassword(ctx context.Context, username, password string) (proto.User, error) {
 	logger := log.FromContext(ctx)
+	be := backend.FromContext(ctx)
+
+	if username != "" && password != "" {
+		user, err := be.User(ctx, username)
+		if err == nil && user != nil && backend.VerifyPassword(password, user.Password()) {
+			return user, nil
+		}
+
+		// Try to authenticate using access token as the password
+		user, err = be.UserByAccessToken(ctx, password)
+		if err == nil {
+			return user, nil
+		}
+
+		logger.Error("invalid password or token", "username", username, "err", err)
+		return nil, ErrInvalidPassword
+	} else if username != "" {
+		// Try to authenticate using access token as the username
+		logger.Info("trying to authenticate using access token as username", "username", username)
+		user, err := be.UserByAccessToken(ctx, username)
+		if err == nil {
+			return user, nil
+		}
 
+		logger.Error("failed to get user", "err", err)
+		return nil, ErrInvalidToken
+	}
+
+	return nil, proto.ErrUserNotFound
+}
+
+// ErrInvalidHeader is returned when the authorization header is invalid.
+var ErrInvalidHeader = errors.New("invalid authorization header")
+
+func parseAuthHdr(r *http.Request) (proto.User, error) {
 	// Check for auth header
 	header := r.Header.Get("Authorization")
-	if header != "" {
-		logger.Debug("authorization", "header", header)
+	if header == "" {
+		return nil, ErrInvalidHeader
+	}
+
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.auth")
+	be := backend.FromContext(ctx)
+
+	logger.Debug("authorization auth header", "header", header)
 
-		parts := strings.SplitN(header, " ", 2)
+	parts := strings.SplitN(header, " ", 2)
+	if len(parts) != 2 {
+		return nil, errors.New("invalid authorization header")
+	}
+
+	switch strings.ToLower(parts[0]) {
+	case "token":
+		user, err := be.UserByAccessToken(ctx, parts[1])
+		if err != nil {
+			logger.Error("failed to get user", "err", err)
+			return nil, err
+		}
+
+		return user, nil
+	case "bearer":
+		claims, err := parseJWT(ctx, parts[1])
+		if err != nil {
+			return nil, err
+		}
+
+		// Find the user
+		parts := strings.SplitN(claims.Subject, "#", 2)
 		if len(parts) != 2 {
-			return nil, errors.New("invalid authorization header")
+			logger.Error("invalid jwt subject", "subject", claims.Subject)
+			return nil, errors.New("invalid jwt subject")
 		}
 
-		// TODO: add basic, and token types
-		be := backend.FromContext(ctx)
-		switch strings.ToLower(parts[0]) {
-		case "bearer":
-			claims, err := getJWTClaims(ctx, parts[1])
-			if err != nil {
-				return nil, err
-			}
-
-			// Find the user
-			parts := strings.SplitN(claims.Subject, "#", 2)
-			if len(parts) != 2 {
-				logger.Error("invalid jwt subject", "subject", claims.Subject)
-				return nil, errors.New("invalid jwt subject")
-			}
-
-			user, err := be.User(ctx, parts[0])
-			if err != nil {
-				logger.Error("failed to get user", "err", err)
-				return nil, err
-			}
-
-			expectedSubject := fmt.Sprintf("%s#%d", user.Username(), user.ID())
-			if expectedSubject != claims.Subject {
-				logger.Error("invalid jwt subject", "subject", claims.Subject, "expected", expectedSubject)
-				return nil, errors.New("invalid jwt subject")
-			}
+		user, err := be.User(ctx, parts[0])
+		if err != nil {
+			logger.Error("failed to get user", "err", err)
+			return nil, err
+		}
 
-			return user, nil
-		default:
-			return nil, errors.New("invalid authorization header")
+		expectedSubject := fmt.Sprintf("%s#%d", user.Username(), user.ID())
+		if expectedSubject != claims.Subject {
+			logger.Error("invalid jwt subject", "subject", claims.Subject, "expected", expectedSubject)
+			return nil, errors.New("invalid jwt subject")
 		}
-	}
 
-	logger.Debug("no authorization header")
+		return user, nil
+	default:
+		username, password, ok := r.BasicAuth()
+		if !ok {
+			return nil, ErrInvalidHeader
+		}
 
-	return nil, proto.ErrUserNotFound
+		return parseUsernamePassword(ctx, username, password)
+	}
 }
 
 // ErrInvalidToken is returned when a token is invalid.
 var ErrInvalidToken = errors.New("invalid token")
 
-func getJWTClaims(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {
+func parseJWT(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {
 	cfg := config.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http.auth")
 	kp, err := cfg.SSH.KeyPair()

server/web/git.go 🔗

@@ -209,6 +209,11 @@ var gitRoutes = []GitRoute{
 	},
 }
 
+func askCredentials(w http.ResponseWriter, _ *http.Request) {
+	w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)
+	w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
+}
+
 // withAccess handles auth.
 func withAccess(next http.Handler) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
@@ -217,16 +222,10 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		be := backend.FromContext(ctx)
 
 		// Store repository in context
+		// We're not checking for errors here because we want to allow
+		// repo creation on the fly.
 		repoName := pat.Param(r, "repo")
-		repo, err := be.Repository(ctx, repoName)
-		if err != nil {
-			if !errors.Is(err, proto.ErrRepoNotFound) {
-				logger.Error("failed to get repository", "err", err)
-			}
-			renderNotFound(w)
-			return
-		}
-
+		repo, _ := be.Repository(ctx, repoName)
 		ctx = proto.WithRepositoryContext(ctx, repo)
 		r = r.WithContext(ctx)
 
@@ -241,6 +240,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		}
 
 		if user == nil && !be.AllowKeyless(ctx) {
+			askCredentials(w, r)
 			renderUnauthorized(w)
 			return
 		}
@@ -266,12 +266,29 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		logger.Info("access level", "repo", repoName, "level", accessLevel)
 
 		file := pat.Param(r, "file")
+
+		// We only allow these services to proceed any other services should return 403
+		// - git-upload-pack
+		// - git-receive-pack
+		// - git-lfs
 		switch service {
+		case git.UploadPackService:
 		case git.ReceivePackService:
 			if accessLevel < access.ReadWriteAccess {
+				askCredentials(w, r)
 				renderUnauthorized(w)
 				return
 			}
+
+			// Create the repo if it doesn't exist.
+			if repo == nil {
+				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
+				if err != nil {
+					logger.Error("failed to create repository", "repo", repoName, "err", err)
+					renderInternalServerError(w)
+					return
+				}
+			}
 		case gitLfsService:
 			switch {
 			case strings.HasPrefix(file, "info/lfs/locks"):
@@ -306,19 +323,27 @@ func withAccess(next http.Handler) http.HandlerFunc {
 				}
 			}
 			if accessLevel < access.ReadOnlyAccess {
-				hdr := `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`
-				w.Header().Set("LFS-Authenticate", hdr)
-				w.Header().Set("WWW-Authenticate", hdr)
+				askCredentials(w, r)
 				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
 					Message: "credentials needed",
 				})
 				return
 			}
 		default:
-			if accessLevel < access.ReadOnlyAccess {
-				renderUnauthorized(w)
-				return
-			}
+			renderForbidden(w)
+			return
+		}
+
+		// If the repo doesn't exist, return 404
+		if repo == nil {
+			renderNotFound(w)
+			return
+		}
+
+		if accessLevel < access.ReadOnlyAccess {
+			askCredentials(w, r)
+			renderUnauthorized(w)
+			return
 		}
 
 		next.ServeHTTP(w, r)
@@ -339,17 +364,6 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 
 	if service == git.ReceivePackService {
 		gitHttpReceiveCounter.WithLabelValues(repoName)
-
-		// Create the repo if it doesn't exist.
-		be := backend.FromContext(ctx)
-		repo := proto.RepositoryFromContext(ctx)
-		if repo == nil {
-			if _, err := be.CreateRepository(ctx, repoName, proto.RepositoryOptions{}); err != nil {
-				logger.Error("failed to create repository", "repo", repoName, "err", err)
-				renderInternalServerError(w)
-				return
-			}
-		}
 	}
 
 	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
@@ -368,6 +382,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 	}
 
 	user := proto.UserFromContext(ctx)
+	cmd.Env = cfg.Environ()
 	cmd.Env = append(cmd.Env, []string{
 		"SOFT_SERVE_REPO_NAME=" + repoName,
 		"SOFT_SERVE_REPO_PATH=" + dir,
@@ -436,6 +451,12 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 		}
 		flusher.Flush()
 	}
+
+	if service == git.ReceivePackService {
+		if err := git.EnsureDefaultBranch(ctx, cmd); err != nil {
+			logger.Errorf("failed to ensure default branch: %s", err)
+		}
+	}
 }
 
 func getInfoRefs(w http.ResponseWriter, r *http.Request) {
@@ -457,6 +478,7 @@ func getInfoRefs(w http.ResponseWriter, r *http.Request) {
 		}
 
 		user := proto.UserFromContext(ctx)
+		cmd.Env = cfg.Environ()
 		cmd.Env = append(cmd.Env, []string{
 			"SOFT_SERVE_REPO_NAME=" + repoName,
 			"SOFT_SERVE_REPO_PATH=" + dir,

server/web/server.go 🔗

@@ -15,6 +15,7 @@ type Route interface {
 }
 
 // NewRouter returns a new HTTP router.
+// TODO: use gorilla/mux and friends
 func NewRouter(ctx context.Context) *goji.Mux {
 	mux := goji.NewMux()
 

testscript/testdata/help.txtar 🔗

@@ -18,6 +18,7 @@ Available Commands:
   repo         Manage repositories
   set-username Set your username
   settings     Manage server settings
+  token        Manage access tokens
   user         Manage users
 
 Flags: