a9e5ace316341976319f2a3865f3feab9e064337

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat: support user access tokens

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

Diff

This diff is truncated to protect this page.

  1diff --git a/go.mod b/go.mod
  2index 7c03a19ecdb9251d50427acff535e87aec4443c3..67d13c4c6dce2a7c8486ccb3c401e6f4f231f70a 100644
  3--- a/go.mod
  4+++ b/go.mod
  5@@ -18,7 +18,9 @@ require (
  6 )
  7 
  8 require (
  9+	github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305
 10 	github.com/caarlos0/env/v8 v8.0.0
 11+	github.com/caarlos0/tablewriter v0.1.0
 12 	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
 13 	github.com/charmbracelet/keygen v0.4.3
 14 	github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
 15@@ -49,7 +51,6 @@ require (
 16 require (
 17 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 18 	github.com/atotto/clipboard v0.1.4 // indirect
 19-	github.com/avast/retry-go v3.0.0+incompatible // indirect
 20 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 21 	github.com/aymerick/douceur v0.2.0 // indirect
 22 	github.com/beorn7/perks v1.0.1 // indirect
 23@@ -57,10 +58,7 @@ require (
 24 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 25 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 26 	github.com/dlclark/regexp2 v1.4.0 // indirect
 27-	github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
 28-	github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
 29 	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
 30-	github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
 31 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 32 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 33 	github.com/golang/protobuf v1.5.3 // indirect
 34@@ -68,7 +66,6 @@ require (
 35 	github.com/gorilla/css v1.0.0 // indirect
 36 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 37 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 38-	github.com/leonelquinteros/gotext v1.5.2 // indirect
 39 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 40 	github.com/mattn/go-isatty v0.0.18 // indirect
 41 	github.com/mattn/go-localereader v0.0.1 // indirect
 42@@ -81,7 +78,6 @@ require (
 43 	github.com/muesli/mango v0.1.0 // indirect
 44 	github.com/muesli/mango-pflag v0.1.0 // indirect
 45 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 46-	github.com/pkg/errors v0.9.1 // indirect
 47 	github.com/prometheus/client_model v0.3.0 // indirect
 48 	github.com/prometheus/common v0.42.0 // indirect
 49 	github.com/prometheus/procfs v0.10.1 // indirect
 50diff --git a/go.sum b/go.sum
 51index bf526c8d886dab318bd9fce79cc26acffa7f9ce7..92f81ebc3856d0d58fd58e6f806946de398e3e1d 100644
 52--- a/go.sum
 53+++ b/go.sum
 54@@ -1,16 +1,9 @@
 55-github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 56-github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 57 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 58 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 59-github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 60 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 61 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 62-github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 63 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 64 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 65-github.com/avast/retry-go v2.4.2+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 66-github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
 67-github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 68 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 69 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 70 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 71@@ -18,10 +11,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 72 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 73 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 74 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 75+github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305 h1:vJpZ14MU1/YhqsAyMst/70MHqRgCkPsIwZNoSgTm2Dc=
 76+github.com/caarlos0/duration v0.0.0-20220103233809-8df7c22fe305/go.mod h1:mSkwb/eZEwOJJJ4tqAKiuhLIPe0e9+FKhlU0oMCpbf8=
 77 github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
 78 github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
 79 github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I=
 80 github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
 81+github.com/caarlos0/tablewriter v0.1.0 h1:HWwl/Zh3GKgVejSeG8lKHc28YBbI7bLRW2tgvxFF2DA=
 82+github.com/caarlos0/tablewriter v0.1.0/go.mod h1:oZ3/mQeP+SC5c1Dr6zv/6jCf0dfsUWq+PuwNw8l3ir0=
 83 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 84 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 85 github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
 86@@ -36,8 +33,6 @@ github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgy
 87 github.com/charmbracelet/keygen v0.4.3/go.mod h1:4e4FT3HSdLU/u83RfJWvzJIaVb8aX4MxtDlfXwpDJaI=
 88 github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
 89 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 90-github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
 91-github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 92 github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU=
 93 github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 94 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
 95@@ -52,24 +47,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 96 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 97 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 98 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 99-github.com/dpotapov/go-spnego v0.0.0-20210315154721-298b63a54430/go.mod h1:AVSs/gZKt1bOd2AhkhbS7Qh56Hv7klde22yXVbwYJhc=
100 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
101 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
102-github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
103-github.com/git-lfs/git-lfs/v3 v3.3.0 h1:cbRy9akD9/hDD7BaVifyNkWkURwC8RSPLzX9+siS+OE=
104-github.com/git-lfs/git-lfs/v3 v3.3.0/go.mod h1:5y2vfVQpxUmceMlraOmmaQ83pYptQYCvPl32ybO2IVw=
105-github.com/git-lfs/gitobj/v2 v2.1.1 h1:tf/VU6zL1kxa3he+nf6FO/syX+LGkm6WGDsMpfuXV7Q=
106-github.com/git-lfs/gitobj/v2 v2.1.1/go.mod h1:q6aqxl6Uu3gWsip5GEKpw+7459F97er8COmU45ncAxw=
107-github.com/git-lfs/go-netrc v0.0.0-20210914205454-f0c862dd687a/go.mod h1:70O4NAtvWn1jW8V8V+OKrJJYcxDLTmIozfi2fmSz5SI=
108-github.com/git-lfs/pktline v0.0.0-20210330133718-06e9096e2825/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
109 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
110 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
111-github.com/git-lfs/wildmatch/v2 v2.0.1 h1:Ds+aobrV5bK0wStILUOn9irllPyf9qrFETbKzwzoER8=
112-github.com/git-lfs/wildmatch/v2 v2.0.1/go.mod h1:EVqonpk9mXbREP3N8UkwoWdrF249uHpCUo5CPXY81gw=
113-github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
114 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
115 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
116-github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
117 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
118 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
119 github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
120@@ -92,46 +75,25 @@ github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu
121 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
122 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
123 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
124-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
125 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
126 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
127 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
128 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
129 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
130-github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
131-github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
132-github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
133 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
134 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
135-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
136 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
137 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
138-github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
139-github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
140-github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
141-github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
142-github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
143-github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
144-github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
145-github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
146 github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
147 github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
148-github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
149-github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
150-github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
151 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
152 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
153-github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
154diff --git a/server/backend/access_token.go b/server/backend/access_token.go
155new file mode 100644
156index 0000000000000000000000000000000000000000..bff3b25ba10474fb2f33b1ee6b1d5230124b566b
157--- /dev/null
158+++ b/server/backend/access_token.go
159@@ -0,0 +1,78 @@
160+package backend
161+
162+import (
163+	"context"
164+	"errors"
165+	"time"
166+
167+	"github.com/charmbracelet/soft-serve/server/db"
168+	"github.com/charmbracelet/soft-serve/server/proto"
169+)
170+
171+// CreateAccessToken creates an access token for user.
172+func (b *Backend) CreateAccessToken(ctx context.Context, user proto.User, name string, expiresAt time.Time) (string, error) {
173+	token := GenerateToken()
174+	tokenHash := HashToken(token)
175+
176+	if err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
177+		_, err := b.store.CreateAccessToken(ctx, tx, name, user.ID(), tokenHash, expiresAt)
178+		if err != nil {
179+			return db.WrapError(err)
180+		}
181+
182+		return nil
183+	}); err != nil {
184+		return "", err
185+	}
186+
187+	return token, nil
188+}
189+
190+// DeleteAccessToken deletes an access token for a user.
191+func (b *Backend) DeleteAccessToken(ctx context.Context, user proto.User, id int64) error {
192+	err := b.db.TransactionContext(ctx, func(tx *db.Tx) error {
193+		_, err := b.store.GetAccessToken(ctx, tx, id)
194+		if err != nil {
195+			return db.WrapError(err)
196+		}
197+
198+		if err := b.store.DeleteAccessTokenForUser(ctx, tx, user.ID(), id); err != nil {
199+			return db.WrapError(err)
200+		}
201+		return nil
202+	})
203+	if err != nil {
204+		if errors.Is(err, db.ErrRecordNotFound) {
205+			return proto.ErrTokenNotFound
206+		}
207+		return err
208+	}
209+
210+	return nil
211+}
212+
213+// ListAccessTokens lists access tokens for a user.
214+func (b *Backend) ListAccessTokens(ctx context.Context, user proto.User) ([]proto.AccessToken, error) {
215+	accessTokens, err := b.store.GetAccessTokensByUserID(ctx, b.db, user.ID())
216+	if err != nil {
217+		return nil, db.WrapError(err)
218+	}
219+
220+	var tokens []proto.AccessToken
221+	for _, t := range accessTokens {
222+		token := proto.AccessToken{
223+			ID:        t.ID,
224+			Name:      t.Name,
225+			TokenHash: t.Token,
226+			UserID:    t.UserID,
227+			CreatedAt: t.CreatedAt,
228+		}
229+		if t.ExpiresAt.Valid {
230+			token.ExpiresAt = t.ExpiresAt.Time
231+		}
232+
233+		tokens = append(tokens, token)
234+	}
235+
236+	return tokens, nil
237+}
238diff --git a/server/backend/auth.go b/server/backend/auth.go
239index 62160fccb22b349e5730de8945956123c7b53ee7..23713347883ddff6c9a2112d27db740f127052c2 100644
240--- a/server/backend/auth.go
241+++ b/server/backend/auth.go
242@@ -2,6 +2,7 @@ package backend
243 
244 import (
245 	"crypto/rand"
246+	"crypto/sha256"
247 	"encoding/hex"
248 
249 	"github.com/charmbracelet/log"
250@@ -26,8 +27,8 @@ func VerifyPassword(password, hash string) bool {
251 	return err == nil
252 }
253 
254-// GenerateAccessToken returns a random unique token.
255-func GenerateAccessToken() string {
256+// GenerateToken returns a random unique token.
257+func GenerateToken() string {
258 	buf := make([]byte, 20)
259 	if _, err := rand.Read(buf); err != nil {
260 		log.Error("unable to generate access token")
261@@ -36,3 +37,9 @@ func GenerateAccessToken() string {
262 
263 	return "ss_" + hex.EncodeToString(buf)
264 }
265+
266+// HashToken hashes the token using sha256.
267+func HashToken(token string) string {
268+	sum := sha256.Sum256([]byte(token + saltySalt))
269+	return hex.EncodeToString(sum[:])
270+}
271diff --git a/server/backend/user.go b/server/backend/user.go
272index ff07660b0f88056c56e723704e849a1dad6095eb..8f1f4bb73c3c3cf3ac4a3b1f5ef8130a93cca846 100644
273--- a/server/backend/user.go
274+++ b/server/backend/user.go
275@@ -4,6 +4,7 @@ import (
276 	"context"
277 	"errors"
278 	"strings"
279+	"time"
280 
281 	"github.com/charmbracelet/soft-serve/server/access"
282 	"github.com/charmbracelet/soft-serve/server/db"
283@@ -117,6 +118,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
284 		if errors.Is(err, db.ErrRecordNotFound) {
285 			return nil, proto.ErrUserNotFound
286 		}
287+		d.logger.Error("error finding user", "username", username, "error", err)
288 		return nil, err
289 	}
290 
291@@ -146,6 +148,46 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
292 		if errors.Is(err, db.ErrRecordNotFound) {
293 			return nil, proto.ErrUserNotFound
294 		}
295+		d.logger.Error("error finding user", "pk", sshutils.MarshalAuthorizedKey(pk), "error", err)
296+		return nil, err
297+	}
298+
299+	return &user{
300+		user:       m,
301+		publicKeys: pks,
302+	}, nil
303+}
304+
305+// UserByAccessToken finds a user by access token.
306+// This also validates the token for expiration and returns proto.ErrTokenExpired.
307+func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.User, error) {
308+	var m models.User
309+	var pks []ssh.PublicKey
310+	token = HashToken(token)
311+
312+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
313+		t, err := d.store.GetAccessTokenByToken(ctx, tx, token)
314+		if err != nil {
315+			return db.WrapError(err)
316+		}
317+
318+		if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) {
319+			return proto.ErrTokenExpired
320+		}
321+
322+		m, err = d.store.FindUserByAccessToken(ctx, tx, token)
323+		if err != nil {
324+			return db.WrapError(err)
325+		}
326+
327+		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
328+		return err
329+	}); err != nil {
330+		err = db.WrapError(err)
331+		if errors.Is(err, db.ErrRecordNotFound) {
332+			return nil, proto.ErrUserNotFound
333+		}
334+		d.logger.Error("failed to find user by access token", "err", err, "token", token)
335 		return nil, err
336 	}
337 
338@@ -335,3 +377,12 @@ func (u *user) Username() string {
339 func (u *user) ID() int64 {
340 	return u.user.ID
341 }
342+
343+// Password implements proto.User.
344+func (u *user) Password() string {
345+	if u.user.Password.Valid {
346+		return u.user.Password.String
347+	}
348+
349+	return ""
350+}
351diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go
352index fcd0ae4f92b1d8a39bcd365f94da2713d308a9bb..1a6070abc70af43e1f8281a7f1563c9b428093b8 100644
353--- a/server/daemon/daemon.go
354+++ b/server/daemon/daemon.go
355@@ -181,15 +181,12 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
356 			return
357 		}
358 
359-		var handler git.ServiceHandler
360 		var counter *prometheus.CounterVec
361 		service := git.Service(split[0])
362 		switch service {
363 		case git.UploadPackService:
364-			handler = git.UploadPack
365 			counter = uploadPackGitCounter
366 		case git.UploadArchiveService:
367-			handler = git.UploadArchive
368 			counter = uploadArchiveGitCounter
369 		default:
370 			d.fatal(c, git.ErrInvalidRequest)
371@@ -289,7 +286,7 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
372 			Dir:    filepath.Join(reposDir, repo),
373 		}
374 
375-		if err := handler(ctx, cmd); err != nil {
376+		if err := service.Handler(ctx, cmd); err != nil {
377 			d.logger.Debugf("git: error handling request: %v", err)
378 			d.fatal(c, err)
379 			return
380diff --git a/server/proto/access_token.go b/server/proto/access_token.go
381new file mode 100644
382index 0000000000000000000000000000000000000000..2876c361356718acc2e3bf975a891b709c76f33a
383--- /dev/null
384+++ b/server/proto/access_token.go
385@@ -0,0 +1,13 @@
386+package proto
387+
388+import "time"
389+
390+// AccessToken represents an access token.
391+type AccessToken struct {
392+	ID        int64
393+	Name      string
394+	UserID    int64
395+	TokenHash string
396+	ExpiresAt time.Time
397+	CreatedAt time.Time
398+}
399diff --git a/server/proto/errors.go b/server/proto/errors.go
400index 781ee6b3296f27cdcad6ab81e1300543a4070a2e..cb453a8f06f5924b8f0ceec2b36bcfa206f6ccee 100644
401--- a/server/proto/errors.go
402+++ b/server/proto/errors.go
403@@ -9,10 +9,14 @@ var (
404 	ErrUnauthorized = errors.New("unauthorized")
405 	// ErrFileNotFound is returned when the file is not found.
406 	ErrFileNotFound = errors.New("file not found")
407-	// ErrRepoNotFound is returned when a repository does not exist.
408+	// ErrRepoNotFound is returned when a repository is not found.
409 	ErrRepoNotFound = errors.New("repository not found")
410 	// ErrRepoExist is returned when a repository already exists.
411 	ErrRepoExist = errors.New("repository already exists")
412-	// ErrUserNotFound is returned when a user does not exist.
413-	ErrUserNotFound = errors.New("user does not exist")
414+	// ErrUserNotFound is returned when a user is not found.
415+	ErrUserNotFound = errors.New("user not found")
416+	// ErrTokenNotFound is returned when a token is not found.
417+	ErrTokenNotFound = errors.New("token not found")
418+	// ErrTokenExpired is returned when a token is expired.
419+	ErrTokenExpired = errors.New("token expired")
420 )
421diff --git a/server/proto/user.go b/server/proto/user.go
422index 647b241101767450fdcd4a1cc725dff10e46055b..7b334122dd617373e575713047149312355a2ed7 100644
423--- a/server/proto/user.go
424+++ b/server/proto/user.go
425@@ -12,6 +12,8 @@ type User interface {
426 	IsAdmin() bool
427 	// PublicKeys returns the user's public keys.
428 	PublicKeys() []ssh.PublicKey
429+	// Password returns the user's password hash.
430+	Password() string
431 }
432 
433 // UserOptions are options for creating a user.
434diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
435index b965409de30e88d3454765ad6b235b05b7d81e92..099102a650fa330d789896703ff81e1dc9d05d7b 100644
436--- a/server/ssh/cmd/cmd.go
437+++ b/server/ssh/cmd/cmd.go
438@@ -154,6 +154,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
439 			pubkeyCommand(),
440 			setUsernameCommand(),
441 			jwtCommand(),
442+			tokenCommand(),
443 		)
444 	}
445 
446diff --git a/server/ssh/cmd/token.go b/server/ssh/cmd/token.go
447new file mode 100644
448index 0000000000000000000000000000000000000000..cb3daad6fe8ef42e0b2bb1c463e45e9d03723bc8
449--- /dev/null
450+++ b/server/ssh/cmd/token.go
451@@ -0,0 +1,154 @@
452+package cmd
453+
454+import (
455+	"strconv"
456+	"strings"
457+	"time"
458+
459+	"github.com/caarlos0/duration"
460+	"github.com/caarlos0/tablewriter"
461+	"github.com/charmbracelet/soft-serve/server/backend"
462+	"github.com/charmbracelet/soft-serve/server/proto"
463+	"github.com/dustin/go-humanize"
464+	"github.com/spf13/cobra"
465+)
466+
467+func tokenCommand() *cobra.Command {
468+	cmd := &cobra.Command{
469+		Use:     "token",
470+		Aliases: []string{"access-token"},
471+		Short:   "Manage access tokens",
472+	}
473+
474+	var createExpiresIn string
475+	createCmd := &cobra.Command{
476+		Use:   "create NAME",
477+		Short: "Create a new access token",
478+		Args:  cobra.MinimumNArgs(1),
479+		RunE: func(cmd *cobra.Command, args []string) error {
480+			ctx := cmd.Context()
481+			be := backend.FromContext(ctx)
482+			name := strings.Join(args, " ")
483+
484+			user := proto.UserFromContext(ctx)
485+			if user == nil {
486+				return proto.ErrUserNotFound
487+			}
488+
489+			var expiresAt time.Time
490+			var expiresIn time.Duration
491+			if createExpiresIn != "" {
492+				d, err := duration.Parse(createExpiresIn)
493+				if err != nil {
494+					return err
495+				}
496+
497+				expiresIn = d
498+				expiresAt = time.Now().Add(d)
499+			}
500+
501+			token, err := be.CreateAccessToken(ctx, user, name, expiresAt)
502+			if err != nil {
503+				return err
504+			}
505+
506+			notice := "Access token created"
507+			if expiresIn != 0 {
508+				notice += " (expires in " + humanize.Time(expiresAt) + ")"
509+			}
510+
511+			cmd.PrintErrln(notice)
512+			cmd.Println(token)
513+
514+			return nil
515+		},
516+	}
517+
518+	createCmd.Flags().StringVar(&createExpiresIn, "expires-in", "", "Token expiration time (e.g. 1y, 3mo, 2w, 5d4h, 1h30m)")
519+
520+	listCmd := &cobra.Command{
521+		Use:     "list",
522+		Aliases: []string{"ls"},
523+		Short:   "List access tokens",
524+		Args:    cobra.NoArgs,
525+		RunE: func(cmd *cobra.Command, _ []string) error {
526+			ctx := cmd.Context()
527+			be := backend.FromContext(ctx)
528+
529+			user := proto.UserFromContext(ctx)
530+			if user == nil {
531+				return proto.ErrUserNotFound
532+			}
533+
534+			tokens, err := be.ListAccessTokens(ctx, user)
535+			if err != nil {
536+				return err
537+			}
538+
539+			if len(tokens) == 0 {
540+				cmd.Println("No tokens found")
541+				return nil
542+			}
543+
544+			now := time.Now()
545+			return tablewriter.Render(
546+				cmd.OutOrStdout(),
547+				tokens,
548+				[]string{"ID", "Name", "Created At", "Expires In"},
549+				func(t proto.AccessToken) ([]string, error) {
550+					expiresAt := "-"
551diff --git a/server/ssh/git.go b/server/ssh/git.go
552index 02b59e47826c3407516d3f02220c85257b494537..52320ed6b1b502a84de99eccc72aeb86efe0470f 100644
553--- a/server/ssh/git.go
554+++ b/server/ssh/git.go
555@@ -93,7 +93,7 @@ func handleGit(s ssh.Session) {
556 			createRepoCounter.WithLabelValues(name).Inc()
557 		}
558 
559-		if err := git.ReceivePack(ctx, cmd); err != nil {
560+		if err := service.Handler(ctx, cmd); err != nil {
561 			sshFatal(s, git.ErrSystemMalfunction)
562 		}
563 
564diff --git a/server/store/access_token.go b/server/store/access_token.go
565index 72440ea2a61d80d1f2121906091cdd28cfe67ffd..c502ea6b15ee14da2b1ec596d2440643820fb204 100644
566--- a/server/store/access_token.go
567+++ b/server/store/access_token.go
568@@ -1 +1,19 @@
569 package store
570+
571+import (
572+	"context"
573+	"time"
574+
575+	"github.com/charmbracelet/soft-serve/server/db"
576+	"github.com/charmbracelet/soft-serve/server/db/models"
577+)
578+
579+// AccessTokenStore is an interface for managing access tokens.
580+type AccessTokenStore interface {
581+	GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error)
582+	GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error)
583+	GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error)
584+	CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error)
585+	DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error
586+	DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error
587+}
588diff --git a/server/store/database/access_token.go b/server/store/database/access_token.go
589new file mode 100644
590index 0000000000000000000000000000000000000000..0ba0d822f66201ef31415ba651ad617742b69fc4
591--- /dev/null
592+++ b/server/store/database/access_token.go
593@@ -0,0 +1,79 @@
594+package database
595+
596+import (
597+	"context"
598+	"time"
599+
600+	"github.com/charmbracelet/soft-serve/server/db"
601+	"github.com/charmbracelet/soft-serve/server/db/models"
602+	"github.com/charmbracelet/soft-serve/server/store"
603+)
604+
605+type accessTokenStore struct{}
606+
607+var _ store.AccessTokenStore = (*accessTokenStore)(nil)
608+
609+// CreateAccessToken implements store.AccessTokenStore.
610+func (s *accessTokenStore) CreateAccessToken(ctx context.Context, h db.Handler, name string, userID int64, token string, expiresAt time.Time) (models.AccessToken, error) {
611+	queryWithoutExpires := `INSERT INTO access_tokens (name, user_id, token, created_at, updated_at)
612+	VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
613+	queryWithExpires := `INSERT INTO access_tokens (name, user_id, token, expires_at, created_at, updated_at)
614+	VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
615+
616+	query := queryWithoutExpires
617+	values := []interface{}{name, userID, token}
618+	if !expiresAt.IsZero() {
619+		query = queryWithExpires
620+		values = append(values, expiresAt)
621+	}
622+
623+	result, err := h.ExecContext(ctx, query, values...)
624+	if err != nil {
625+		return models.AccessToken{}, err
626+	}
627+
628+	id, err := result.LastInsertId()
629+	if err != nil {
630+		return models.AccessToken{}, err
631+	}
632+
633+	return s.GetAccessToken(ctx, h, id)
634+}
635+
636+// DeleteAccessToken implements store.AccessTokenStore.
637+func (*accessTokenStore) DeleteAccessToken(ctx context.Context, h db.Handler, id int64) error {
638+	query := h.Rebind(`DELETE FROM access_tokens WHERE id = ?`)
639+	_, err := h.ExecContext(ctx, query, id)
640+	return err
641+}
642+
643+// DeleteAccessTokenForUser implements store.AccessTokenStore.
644+func (*accessTokenStore) DeleteAccessTokenForUser(ctx context.Context, h db.Handler, userID int64, id int64) error {
645+	query := h.Rebind(`DELETE FROM access_tokens WHERE user_id = ? AND id = ?`)
646+	_, err := h.ExecContext(ctx, query, userID, id)
647+	return err
648+}
649+
650+// GetAccessToken implements store.AccessTokenStore.
651+func (*accessTokenStore) GetAccessToken(ctx context.Context, h db.Handler, id int64) (models.AccessToken, error) {
652+	query := h.Rebind(`SELECT * FROM access_tokens WHERE id = ?`)
653+	var m models.AccessToken
654+	err := h.GetContext(ctx, &m, query, id)
655+	return m, err
656+}
657+
658+// GetAccessTokensByUserID implements store.AccessTokenStore.
659+func (*accessTokenStore) GetAccessTokensByUserID(ctx context.Context, h db.Handler, userID int64) ([]models.AccessToken, error) {
660+	query := h.Rebind(`SELECT * FROM access_tokens WHERE user_id = ?`)
661+	var m []models.AccessToken
662+	err := h.SelectContext(ctx, &m, query, userID)
663+	return m, err
664+}
665+
666+// GetAccessTokenByToken implements store.AccessTokenStore.
667+func (*accessTokenStore) GetAccessTokenByToken(ctx context.Context, h db.Handler, token string) (models.AccessToken, error) {
668+	query := h.Rebind(`SELECT * FROM access_tokens WHERE token = ?`)
669+	var m models.AccessToken
670+	err := h.GetContext(ctx, &m, query, token)
671+	return m, err
672+}
673diff --git a/server/store/database/database.go b/server/store/database/database.go
674index c63d70992eb4665e8dd1ecf6bf007c5eb4653c41..f05ee4fa079ae1a2a13fb750e8b6a48773ea1f12 100644
675--- a/server/store/database/database.go
676+++ b/server/store/database/database.go
677@@ -20,6 +20,7 @@ type datastore struct {
678 	*userStore
679 	*collabStore
680 	*lfsStore
681+	*accessTokenStore
682 }
683 
684 // New returns a new store.Store database.
685@@ -33,11 +34,12 @@ func New(ctx context.Context, db *db.DB) store.Store {
686 		db:     db,
687 		logger: logger,
688 
689-		settingsStore: &settingsStore{},
690-		repoStore:     &repoStore{},
691-		userStore:     &userStore{},
692-		collabStore:   &collabStore{},
693-		lfsStore:      &lfsStore{},
694+		settingsStore:    &settingsStore{},
695+		repoStore:        &repoStore{},
696+		userStore:        &userStore{},
697+		collabStore:      &collabStore{},
698+		lfsStore:         &lfsStore{},
699+		accessTokenStore: &accessTokenStore{},
700 	}
701 
702 	return s
703diff --git a/server/store/database/user.go b/server/store/database/user.go
704index c5b50681b0ab5ef9ca3b70fbe044cd2710c43bb3..5f00f45b75295cdcc92cb148cfe8197cf1c022be 100644
705--- a/server/store/database/user.go
706+++ b/server/store/database/user.go
707@@ -112,6 +112,17 @@ func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, usernam
708 	return m, err
709 }
710 
711+// FindUserByAccessToken implements store.UserStore.
712+func (*userStore) FindUserByAccessToken(ctx context.Context, tx db.Handler, token string) (models.User, error) {
713+	var m models.User
714+	query := tx.Rebind(`SELECT users.*
715+			FROM users
716+			INNER JOIN access_tokens ON users.id = access_tokens.user_id
717+			WHERE access_tokens.token = ?;`)
718+	err := tx.GetContext(ctx, &m, query, token)
719+	return m, err
720+}
721+
722 // GetAllUsers implements store.UserStore.
723 func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) {
724 	var ms []models.User
725diff --git a/server/store/store.go b/server/store/store.go
726index 9dff3ffcc37cefe197e5f24a3e0d3ef3dffe097b..7862cb598b39702aa6b815524dc91a34da59e843 100644
727--- a/server/store/store.go
728+++ b/server/store/store.go
729@@ -7,4 +7,5 @@ type Store interface {
730 	CollaboratorStore
731 	SettingStore
732 	LFSStore
733+	AccessTokenStore
734 }
735diff --git a/server/store/user.go b/server/store/user.go
736index b63f53a8fba8fc3c038ded128eb3ae5d37ac887c..43c5237366ba288077372754e669651121c40282 100644
737--- a/server/store/user.go
738+++ b/server/store/user.go
739@@ -13,6 +13,7 @@ type UserStore interface {
740 	GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
741 	FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
742 	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
743+	FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error)
744 	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
745 	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
746 	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
747diff --git a/server/web/auth.go b/server/web/auth.go
748index 067108fe41435c4b7164eee56d2b4d1579fe31b2..2a42daa1686598a5273d1cd41bd5a25268397d96 100644
749--- a/server/web/auth.go
750+++ b/server/web/auth.go
751@@ -16,62 +16,121 @@ import (
752 
753 // authenticate authenticates the user from the request.
754 func authenticate(r *http.Request) (proto.User, error) {
755-	ctx := r.Context()
756+	// Prefer the Authorization header
757+	user, err := parseAuthHdr(r)
758+	if err != nil || user == nil {
759+		return nil, proto.ErrUserNotFound
760+	}
761+
762+	return user, nil
763+}
764+
765+// ErrInvalidPassword is returned when the password is invalid.
766+var ErrInvalidPassword = errors.New("invalid password")
767+
768+func parseUsernamePassword(ctx context.Context, username, password string) (proto.User, error) {
769 	logger := log.FromContext(ctx)
770+	be := backend.FromContext(ctx)
771+
772+	if username != "" && password != "" {
773+		user, err := be.User(ctx, username)
774+		if err == nil && user != nil && backend.VerifyPassword(password, user.Password()) {
775+			return user, nil
776+		}
777+
778+		// Try to authenticate using access token as the password
779+		user, err = be.UserByAccessToken(ctx, password)
780+		if err == nil {
781+			return user, nil
782+		}
783+
784+		logger.Error("invalid password or token", "username", username, "err", err)
785+		return nil, ErrInvalidPassword
786+	} else if username != "" {
787+		// Try to authenticate using access token as the username
788+		logger.Info("trying to authenticate using access token as username", "username", username)
789+		user, err := be.UserByAccessToken(ctx, username)
790+		if err == nil {
791+			return user, nil
792+		}
793 
794+		logger.Error("failed to get user", "err", err)
795+		return nil, ErrInvalidToken
796+	}
797+
798+	return nil, proto.ErrUserNotFound
799+}
800+
801+// ErrInvalidHeader is returned when the authorization header is invalid.
802+var ErrInvalidHeader = errors.New("invalid authorization header")
803+
804+func parseAuthHdr(r *http.Request) (proto.User, error) {
805 	// Check for auth header
806 	header := r.Header.Get("Authorization")
807-	if header != "" {
808-		logger.Debug("authorization", "header", header)
809+	if header == "" {
810+		return nil, ErrInvalidHeader
811+	}
812+
813+	ctx := r.Context()
814+	logger := log.FromContext(ctx).WithPrefix("http.auth")
815+	be := backend.FromContext(ctx)
816+
817+	logger.Debug("authorization auth header", "header", header)
818 
819-		parts := strings.SplitN(header, " ", 2)
820+	parts := strings.SplitN(header, " ", 2)
821+	if len(parts) != 2 {
822+		return nil, errors.New("invalid authorization header")
823+	}
824+
825+	switch strings.ToLower(parts[0]) {
826+	case "token":
827+		user, err := be.UserByAccessToken(ctx, parts[1])
828+		if err != nil {
829+			logger.Error("failed to get user", "err", err)
830+			return nil, err
831+		}
832+
833+		return user, nil
834+	case "bearer":
835+		claims, err := parseJWT(ctx, parts[1])
836+		if err != nil {
837+			return nil, err
838+		}
839+
840+		// Find the user
841+		parts := strings.SplitN(claims.Subject, "#", 2)
842 		if len(parts) != 2 {
843-			return nil, errors.New("invalid authorization header")
844+			logger.Error("invalid jwt subject", "subject", claims.Subject)
845+			return nil, errors.New("invalid jwt subject")
846 		}
847 
848-		// TODO: add basic, and token types
849-		be := backend.FromContext(ctx)
850-		switch strings.ToLower(parts[0]) {
851diff --git a/server/web/git.go b/server/web/git.go
852index b781f643142bf676ac75449fa2154719c0f7dfb8..8ee678dbd908d8f8a7d3d1b8bf492fec06e11593 100644
853--- a/server/web/git.go
854+++ b/server/web/git.go
855@@ -209,6 +209,11 @@ var gitRoutes = []GitRoute{
856 	},
857 }
858 
859+func askCredentials(w http.ResponseWriter, _ *http.Request) {
860+	w.Header().Set("WWW-Authenticate", `Basic realm="Git" charset="UTF-8", Token, Bearer`)
861+	w.Header().Set("LFS-Authenticate", `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`)
862+}
863+
864 // withAccess handles auth.
865 func withAccess(next http.Handler) http.HandlerFunc {
866 	return func(w http.ResponseWriter, r *http.Request) {
867@@ -217,16 +222,10 @@ func withAccess(next http.Handler) http.HandlerFunc {
868 		be := backend.FromContext(ctx)
869 
870 		// Store repository in context
871+		// We're not checking for errors here because we want to allow
872+		// repo creation on the fly.
873 		repoName := pat.Param(r, "repo")
874-		repo, err := be.Repository(ctx, repoName)
875-		if err != nil {
876-			if !errors.Is(err, proto.ErrRepoNotFound) {
877-				logger.Error("failed to get repository", "err", err)
878-			}
879-			renderNotFound(w)
880-			return
881-		}
882-
883+		repo, _ := be.Repository(ctx, repoName)
884 		ctx = proto.WithRepositoryContext(ctx, repo)
885 		r = r.WithContext(ctx)
886 
887@@ -241,6 +240,7 @@ func withAccess(next http.Handler) http.HandlerFunc {
888 		}
889 
890 		if user == nil && !be.AllowKeyless(ctx) {
891+			askCredentials(w, r)
892 			renderUnauthorized(w)
893 			return
894 		}
895@@ -266,12 +266,29 @@ func withAccess(next http.Handler) http.HandlerFunc {
896 		logger.Info("access level", "repo", repoName, "level", accessLevel)
897 
898 		file := pat.Param(r, "file")
899+
900+		// We only allow these services to proceed any other services should return 403
901+		// - git-upload-pack
902+		// - git-receive-pack
903+		// - git-lfs
904 		switch service {
905+		case git.UploadPackService:
906 		case git.ReceivePackService:
907 			if accessLevel < access.ReadWriteAccess {
908+				askCredentials(w, r)
909 				renderUnauthorized(w)
910 				return
911 			}
912+
913+			// Create the repo if it doesn't exist.
914+			if repo == nil {
915+				repo, err = be.CreateRepository(ctx, repoName, proto.RepositoryOptions{})
916+				if err != nil {
917+					logger.Error("failed to create repository", "repo", repoName, "err", err)
918+					renderInternalServerError(w)
919+					return
920+				}
921+			}
922 		case gitLfsService:
923 			switch {
924 			case strings.HasPrefix(file, "info/lfs/locks"):
925@@ -306,19 +323,27 @@ func withAccess(next http.Handler) http.HandlerFunc {
926 				}
927 			}
928 			if accessLevel < access.ReadOnlyAccess {
929-				hdr := `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`
930-				w.Header().Set("LFS-Authenticate", hdr)
931-				w.Header().Set("WWW-Authenticate", hdr)
932+				askCredentials(w, r)
933 				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
934 					Message: "credentials needed",
935 				})
936 				return
937 			}
938 		default:
939-			if accessLevel < access.ReadOnlyAccess {
940-				renderUnauthorized(w)
941-				return
942-			}
943+			renderForbidden(w)
944+			return
945+		}
946+
947+		// If the repo doesn't exist, return 404
948+		if repo == nil {
949+			renderNotFound(w)
950+			return
951+		}
952+
953+		if accessLevel < access.ReadOnlyAccess {
954+			askCredentials(w, r)
955diff --git a/server/web/server.go b/server/web/server.go
956index 44c913c0c924d9d691d66e35cc0e3a6e79e3b953..cdb854726943a823009720f43a24dcee4124fef2 100644
957--- a/server/web/server.go
958+++ b/server/web/server.go
959@@ -15,6 +15,7 @@ type Route interface {
960 }
961 
962 // NewRouter returns a new HTTP router.
963+// TODO: use gorilla/mux and friends
964 func NewRouter(ctx context.Context) *goji.Mux {
965 	mux := goji.NewMux()
966 
967diff --git a/testscript/testdata/help.txtar b/testscript/testdata/help.txtar
968index 64a7c7e6e1afeedc9c6d232cf169f2a771ad95f8..58868b7cc750df0c591117f608978605b38e3da4 100644
969--- a/testscript/testdata/help.txtar
970+++ b/testscript/testdata/help.txtar
971@@ -18,6 +18,7 @@ Available Commands:
972   repo         Manage repositories
973   set-username Set your username
974   settings     Manage server settings
975+  token        Manage access tokens
976   user         Manage users
977 
978 Flags: