Skip to content

Commit 4e9c234

Browse files
committed
feat: add github device flow for authentication
This will allow us to add a GitHub OAuth provider out-of-the-box to reduce setup requirements.
1 parent 98457e9 commit 4e9c234

22 files changed

+901
-372
lines changed

cli/server.go

+8
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ func ReadGitAuthProvidersFromEnv(environ []string) ([]codersdk.GitAuthConfig, er
148148
provider.ValidateURL = v.Value
149149
case "REGEX":
150150
provider.Regex = v.Value
151+
case "DEVICE_FLOW":
152+
b, err := strconv.ParseBool(v.Value)
153+
if err != nil {
154+
return nil, xerrors.Errorf("parse bool: %s", v.Value)
155+
}
156+
provider.DeviceFlow = b
157+
case "DEVICE_AUTH_URL":
158+
provider.DeviceAuthURL = v.Value
151159
case "NO_REFRESH":
152160
b, err := strconv.ParseBool(v.Value)
153161
if err != nil {

coderd/apidoc/docs.go

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+11-5
Original file line numberDiff line numberDiff line change
@@ -458,11 +458,17 @@ func New(options *Options) *API {
458458
r.Route("/gitauth", func(r chi.Router) {
459459
for _, gitAuthConfig := range options.GitAuthConfigs {
460460
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
461-
r.Use(
462-
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
463-
apiKeyMiddlewareRedirect,
464-
)
465-
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
461+
r.Use(apiKeyMiddlewareRedirect)
462+
463+
useDeviceAuth := gitAuthConfig.DeviceAuth != nil
464+
if useDeviceAuth {
465+
r.Use(api.gitAuthDeviceRedirect(gitAuthConfig))
466+
r.Post("/exchange", api.postGitAuthExchange(gitAuthConfig))
467+
} else {
468+
// If device auth isn't in use, then the git provider is using OAuth2!
469+
r.Use(httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil))
470+
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
471+
}
466472
})
467473
}
468474
})

coderd/gitauth.go

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package coderd
2+
3+
import (
4+
"database/sql"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
10+
"github.com/coder/coder/coderd/database"
11+
"github.com/coder/coder/coderd/gitauth"
12+
"github.com/coder/coder/coderd/httpapi"
13+
"github.com/coder/coder/coderd/httpmw"
14+
"github.com/coder/coder/codersdk"
15+
)
16+
17+
func (*API) gitAuthDeviceRedirect(gitAuthConfig *gitauth.Config) func(http.Handler) http.Handler {
18+
route := fmt.Sprintf("/gitauth/%s/device", gitAuthConfig.ID)
19+
return func(next http.Handler) http.Handler {
20+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+
deviceCode := r.URL.Query().Get("device_code")
22+
if r.Method != http.MethodGet || deviceCode != "" {
23+
next.ServeHTTP(w, r)
24+
return
25+
}
26+
// If no device code is provided, redirect to the dashboard with query params!
27+
deviceAuth, err := gitAuthConfig.DeviceAuth.AuthorizeDevice(r.Context())
28+
if err != nil {
29+
httpapi.Write(r.Context(), w, http.StatusInternalServerError, codersdk.Response{
30+
Message: "Failed to authorize device.",
31+
Detail: err.Error(),
32+
})
33+
return
34+
}
35+
v := url.Values{
36+
"device_code": {deviceAuth.DeviceCode},
37+
"user_code": {deviceAuth.UserCode},
38+
"expires_in": {fmt.Sprintf("%d", deviceAuth.ExpiresIn)},
39+
"interval": {fmt.Sprintf("%d", deviceAuth.Interval)},
40+
"verification_uri": {deviceAuth.VerificationURI},
41+
}
42+
http.Redirect(w, r, fmt.Sprintf("%s?%s", route, v.Encode()), http.StatusTemporaryRedirect)
43+
})
44+
}
45+
}
46+
47+
func (api *API) postGitAuthExchange(gitAuthConfig *gitauth.Config) http.HandlerFunc {
48+
return func(rw http.ResponseWriter, r *http.Request) {
49+
ctx := r.Context()
50+
apiKey := httpmw.APIKey(r)
51+
52+
var req codersdk.ExchangeGitAuthRequest
53+
if !httpapi.Read(ctx, rw, r, &req) {
54+
return
55+
}
56+
57+
if gitAuthConfig.DeviceAuth == nil {
58+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
59+
Message: "Git auth provider does not support device flow.",
60+
})
61+
return
62+
}
63+
64+
token, err := gitAuthConfig.DeviceAuth.ExchangeDeviceCode(ctx, req.DeviceCode)
65+
if err != nil {
66+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
67+
Message: "Failed to exchange device code.",
68+
Detail: err.Error(),
69+
})
70+
return
71+
}
72+
73+
_, err = api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
74+
ProviderID: gitAuthConfig.ID,
75+
UserID: apiKey.UserID,
76+
})
77+
if err != nil {
78+
if !errors.Is(err, sql.ErrNoRows) {
79+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
80+
Message: "Failed to get git auth link.",
81+
Detail: err.Error(),
82+
})
83+
return
84+
}
85+
86+
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
87+
ProviderID: gitAuthConfig.ID,
88+
UserID: apiKey.UserID,
89+
CreatedAt: database.Now(),
90+
UpdatedAt: database.Now(),
91+
OAuthAccessToken: token.AccessToken,
92+
OAuthRefreshToken: token.RefreshToken,
93+
OAuthExpiry: token.Expiry,
94+
})
95+
if err != nil {
96+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
97+
Message: "Failed to insert git auth link.",
98+
Detail: err.Error(),
99+
})
100+
return
101+
}
102+
} else {
103+
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
104+
ProviderID: gitAuthConfig.ID,
105+
UserID: apiKey.UserID,
106+
UpdatedAt: database.Now(),
107+
OAuthAccessToken: token.AccessToken,
108+
OAuthRefreshToken: token.RefreshToken,
109+
OAuthExpiry: token.Expiry,
110+
})
111+
if err != nil {
112+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
113+
Message: "Failed to update git auth link.",
114+
Detail: err.Error(),
115+
})
116+
return
117+
}
118+
}
119+
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
120+
}
121+
}
122+
123+
// device get
124+
func (api *API) gitAuthCallback(gitAuthConfig *gitauth.Config) http.HandlerFunc {
125+
return func(rw http.ResponseWriter, r *http.Request) {
126+
var (
127+
ctx = r.Context()
128+
state = httpmw.OAuth2(r)
129+
apiKey = httpmw.APIKey(r)
130+
)
131+
132+
_, err := api.Database.GetGitAuthLink(ctx, database.GetGitAuthLinkParams{
133+
ProviderID: gitAuthConfig.ID,
134+
UserID: apiKey.UserID,
135+
})
136+
if err != nil {
137+
if !errors.Is(err, sql.ErrNoRows) {
138+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
139+
Message: "Failed to get git auth link.",
140+
Detail: err.Error(),
141+
})
142+
return
143+
}
144+
145+
_, err = api.Database.InsertGitAuthLink(ctx, database.InsertGitAuthLinkParams{
146+
ProviderID: gitAuthConfig.ID,
147+
UserID: apiKey.UserID,
148+
CreatedAt: database.Now(),
149+
UpdatedAt: database.Now(),
150+
OAuthAccessToken: state.Token.AccessToken,
151+
OAuthRefreshToken: state.Token.RefreshToken,
152+
OAuthExpiry: state.Token.Expiry,
153+
})
154+
if err != nil {
155+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
156+
Message: "Failed to insert git auth link.",
157+
Detail: err.Error(),
158+
})
159+
return
160+
}
161+
} else {
162+
_, err = api.Database.UpdateGitAuthLink(ctx, database.UpdateGitAuthLinkParams{
163+
ProviderID: gitAuthConfig.ID,
164+
UserID: apiKey.UserID,
165+
UpdatedAt: database.Now(),
166+
OAuthAccessToken: state.Token.AccessToken,
167+
OAuthRefreshToken: state.Token.RefreshToken,
168+
OAuthExpiry: state.Token.Expiry,
169+
})
170+
if err != nil {
171+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
172+
Message: "Failed to update git auth link.",
173+
Detail: err.Error(),
174+
})
175+
return
176+
}
177+
}
178+
179+
redirect := state.Redirect
180+
if redirect == "" {
181+
// This is a nicely rendered screen on the frontend
182+
redirect = "/gitauth"
183+
}
184+
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
185+
}
186+
}

coderd/gitauth/config.go

+21-3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ type Config struct {
3636
// returning it to the user. If omitted, tokens will
3737
// not be validated before being returned.
3838
ValidateURL string
39+
// DeviceAuth is set if the provider uses the device flow.
40+
DeviceAuth *DeviceAuth
3941
}
4042

4143
// RefreshToken automatically refreshes the token if expired and permitted.
@@ -187,17 +189,33 @@ func ConvertConfig(entries []codersdk.GitAuthConfig, accessURL *url.URL) ([]*Con
187189
var oauthConfig httpmw.OAuth2Config = oauth2Config
188190
// Azure DevOps uses JWT token authentication!
189191
if typ == codersdk.GitProviderAzureDevops {
190-
oauthConfig = newJWTOAuthConfig(oauth2Config)
192+
oauthConfig = &jwtConfig{oauth2Config}
191193
}
192194

193-
configs = append(configs, &Config{
195+
cfg := &Config{
194196
OAuth2Config: oauthConfig,
195197
ID: entry.ID,
196198
Regex: regex,
197199
Type: typ,
198200
NoRefresh: entry.NoRefresh,
199201
ValidateURL: entry.ValidateURL,
200-
})
202+
}
203+
204+
if entry.DeviceFlow {
205+
if entry.DeviceAuthURL == "" {
206+
entry.DeviceAuthURL = deviceAuthURL[typ]
207+
}
208+
if entry.DeviceAuthURL == "" {
209+
return nil, xerrors.Errorf("git auth provider %q: device auth url must be provided", entry.ID)
210+
}
211+
cfg.DeviceAuth = &DeviceAuth{
212+
config: oauth2Config,
213+
URL: entry.DeviceAuthURL,
214+
ID: entry.ID,
215+
}
216+
}
217+
218+
configs = append(configs, cfg)
201219
}
202220
return configs, nil
203221
}

coderd/gitauth/config_test.go

+9
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,15 @@ func TestConvertYAML(t *testing.T) {
169169
Regex: `\K`,
170170
}},
171171
Error: "compile regex for git auth provider",
172+
}, {
173+
Name: "NoDeviceURL",
174+
Input: []codersdk.GitAuthConfig{{
175+
Type: string(codersdk.GitProviderGitLab),
176+
ClientID: "example",
177+
ClientSecret: "example",
178+
DeviceFlow: true,
179+
}},
180+
Error: "device auth url must be provided",
172181
}} {
173182
tc := tc
174183
t.Run(tc.Name, func(t *testing.T) {

0 commit comments

Comments
 (0)