a9e5ace316341976319f2a3865f3feab9e064337
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
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: