10000 Add OAuth2 auth, token, and revoke routes · coder/coder@e1365ae · GitHub
[go: up one dir, main page]

Skip to content

Commit e1365ae

Browse files
committed
Add OAuth2 auth, token, and revoke routes
1 parent 7b7ba23 commit e1365ae

File tree

8 files changed

+1183
-4
lines changed

8 files changed

+1183
-4
lines changed

codersdk/oauth2.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,48 @@ func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.U
179179
}
180180
return nil
181181
}
182+
183+
type OAuth2ProviderGrantType string
184+
185+
const (
186+
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
187+
)
188+
189+
func (e OAuth2ProviderGrantType) Valid() bool {
190+
switch e {
191+
case OAuth2ProviderGrantTypeAuthorizationCode:
192+
return true
193+
}
194+
return false
195+
}
196+
197+
type OAuth2ProviderResponseType string
198+
199+
const (
200+
OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code"
201+
)
202+
203+
func (e OAuth2ProviderResponseType) Valid() bool {
204+
switch e {
205+
case OAuth2ProviderResponseTypeCode:
206+
return true
207+
}
208+
return false
209+
}
210+
211+
// RevokeOAuth2ProviderApp completely revokes an app's access for the user.
212+
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
213+
res, err := c.Request(ctx, http.MethodDelete, "/login/oauth2/tokens", nil, func(r *http.Request) {
214+
q := r.URL.Query()
215+
q.Set("client_id", appID.String())
216+
r.URL.RawQuery = q.Encode()
217+
})
218+
if err != nil {
219+
return err
220+
}
221+
defer res.Body.Close()
222+
if res.StatusCode != http.StatusNoContent {
223+
return ReadBodyAsError(res)
224+
}
225+
return nil
226+
}

enterprise/coderd/coderd.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,28 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
164164
return nil, xerrors.Errorf("failed to get deployment ID: %w", err)
165165
}
166166

167+
api.AGPL.RootHandler.Group(func(r chi.Router) {
168+
r.Use(
169+
api.oAuth2ProviderMiddleware,
170+
// Fetch the app as system because in the /tokens route there will be no
171+
// authenticated user.
172+
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
173+
)
174+
// Oauth2 linking routes do not make sense under the /api/v2 path.
175+
r.Route("/login", func(r chi.Router) {
176+
r.Route("/oauth2", func(r chi.Router) {
177+
r.Group(func(r chi.Router) {
178+
r.Use(apiKeyMiddleware)
179+
r.Get("/authorize", api.postOAuth2ProviderAppAuthorize())
180+
r.Delete("/tokens", api.deleteOAuth2ProviderAppTokens())
181+
})
182+
// The /tokens endpoint will be called from an unauthorized client so we
183+
// cannot require an API key.
184+
r.Post("/tokens", api.postOAuth2ProviderAppToken())
185+
})
186+
})
187+
})
188+
167189
api.AGPL.APIHandler.Group(func(r chi.Router) {
168190
r.Get("/entitlements", api.serveEntitlements)
169191
// /regions overrides the AGPL /regions endpoint
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package identityprovider
2+
3+
import (
4+
"database/sql"
5+
"errors"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"strings"
10+
"time"
11+
12+
"github.com/google/uuid"
13+
"golang.org/x/xerrors"
14+
15+
"github.com/coder/coder/v2/coderd/database"
16+
"github.com/coder/coder/v2/coderd/database/dbtime"
17+
"github.com/coder/coder/v2/coderd/httpapi"
18+
"github.com/coder/coder/v2/coderd/httpmw"
19+
"github.com/coder/coder/v2/codersdk"
20+
"github.com/coder/coder/v2/cryptorand"
21+
)
22+
23+
type authorizeParams struct {
24+
clientID string
25+
redirectURL *url.URL
26+
responseType codersdk.OAuth2ProviderResponseType
27+
scope []string
28+
state string
29+
}
30+
31+
func extractAuthorizeParams(r *http.Request, callbackURL string) (authorizeParams, []codersdk.ValidationError, error) {
32+
p := httpapi.NewQueryParamParser()
33+
vals := r.URL.Query()
34+
35+
p.Required("state", "response_type", "client_id")
36+
37+
// TODO: Can we make this a URL straight out of the database?
38+
cb, err := url.Parse(callbackURL)
39+
if err != nil {
40+
return authorizeParams{}, nil, err
41+
}
42+
params := authorizeParams{
43+
clientID: p.String(vals, "", "client_id"),
44+
redirectURL: p.URL(vals, cb, "redirect_uri"),
45+
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
46+
scope: p.Strings(vals, []string{}, "scope"),
47+
state: p.String(vals, "", "state"),
48+
}
49+
50+
// We add "redirected" when coming from the authorize page.
51+
_ = p.String(vals, "", "redirected")
52+
53+
if err := validateRedirectURL(cb, params.redirectURL.String()); err != nil {
54+
p.Errors = append(p.Errors, codersdk.ValidationError{
55+
Field: "redirect_uri",
56+
Detail: fmt.Sprintf("Query param %q is invalid", "redirect_uri"),
57+
})
58+
}
59+
60+
p.ErrorExcessParams(vals)
61+
return params, p.Errors, nil
62+
}
63+
64+
/**
65+
* Authorize displays an HTML for authorizing an application when the user has
66+
* first been redirected to this path and generates a code and redirects to the
67+
* app's callback URL after the user clicks "allow" on that page.
68+
*/
69+
func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
70+
handler := func(rw http.ResponseWriter, r *http.Request) {
71+
ctx := r.Context()
72+
apiKey := httpmw.APIKey(r)
73+
app := httpmw.OAuth2ProviderApp(r)
74+
75+
params, validationErrs, err := extractAuthorizeParams(r, app.CallbackURL)
76+
if err != nil {
77+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
78+
Message: "Failed to validate query parameters.",
79+
Detail: err.Error(),
80+
})
81+
return
82+
}
83+
if len(validationErrs) > 0 {
84+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
85+
Message: "Invalid query params.",
86+
Validations: validationErrs,
87+
})
88+
return
89+
}
90+
91+
// TODO: Ignoring scope for now, but should look into implementing.
92+
// 40 characters matches the length of GitHub's client secrets.
93+
rawCode, err := cryptorand.String(40)
94+
if err != nil {
95+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
96+
Message: "Failed to generate OAuth2 app authorization code.",
97+
})
98+
return
99+
}
100+
hashedCode := Hash(rawCode, app.ID)
101+
err = db.InTx(func(tx database.Store) error {
102+
// Delete any previous codes.
103+
err = tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
104+
AppID: app.ID,
105+
UserID: apiKey.UserID,
106+
})
107+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
108+
return xerrors.Errorf("delete oauth2 app codes: %w", err)
109+
}
110+
111+
// Insert the new code.
112+
_, err = tx.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
113+
ID: uuid.New(),
114+
CreatedAt: dbtime.Now(),
115+
// TODO: Configurable expiration? Ten minutes matches GitHub.
116+
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
117+
HashedSecret: hashedCode[:],
118+
AppID: app.ID,
119+
UserID: apiKey.UserID,
120+
})
121+
if err != nil {
122+
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
123+
}
124+
125+
return nil
126+
}, nil)
127+
if err != nil {
128+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
129+
Message: "Failed to generate OAuth2 authorization code.",
130+
Detail: err.Error(),
131+
})
132+
return
133+
}
134+
135+
newQuery := params.redirectURL.Query()
136+
newQuery.Add("code", rawCode)
137+
newQuery.Add("state", params.state)
138+
params.redirectURL.RawQuery = newQuery.Encode()
139+
140+
http.Redirect(rw, r, params.redirectURL.String(), http.StatusTemporaryRedirect)
141+
}
142+
143+
// Always wrap with its custom mw.
144+
return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP
145+
}
146+
147+
// validateRedirectURL validates that the redirectURL is contained in baseURL.
148+
func validateRedirectURL(baseURL *url.URL, redirectURL string) error {
149+
redirect, err := url.Parse(redirectURL)
150+
if err != nil {
151+
return err
152+
}
153+
// It can be a sub-directory but not a sub-domain, as we have apps on
154+
// sub-domains so it seems too dangerous.
155+
if redirect.Host != baseURL.Host || !strings.HasPrefix(redirect.Path, baseURL.Path) {
156+
return xerrors.New("invalid redirect URL")
157+
}
158+
return nil
159+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package identityprovider
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
7+
"github.com/coder/coder/v2/coderd/httpapi"
8+
"github.com/coder/coder/v2/coderd/httpmw"
9+
"github.com/coder/coder/v2/codersdk"
10+
"github.com/coder/coder/v2/site"
11+
)
12+
13+
// authorizeMW serves to remove some code from the primary authorize handler.
14+
// It decides when to show the html allow page, and when to just continue.
15+
func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler {
16+
return func(next http.Handler) http.Handler {
17+
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
18+
origin := r.Header.Get(httpmw.OriginHeader)
19+
originU, err := url.Parse(origin)
20+
if err != nil {
21+
// TODO: Curl requests will not have this. One idea is to always show
22+
// html here??
23+
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
24+
Message: "Invalid or missing origin header.",
25+
Detail: err.Error(),
26+
})
27+
return
28+
}
29+
30+
referer := r.Referer()
31+
refererU, err := url.Parse(referer)
32+
if err != nil {
33+
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
34+
Message: "Invalid or missing referer header.",
35+
Detail: err.Error(),
36+
})
37+
return
38+
}
39+
40+
app := httpmw.OAuth2ProviderApp(r)
41+
ua := httpmw.UserAuthorization(r)
42+
43+
// If the request comes from outside, then we show the html allow page.
44+
// TODO: Skip this step if the user has already clicked allow before, and
45+
// we can just reuse the token.
46+
if originU.Hostname() != accessURL.Hostname() && refererU.Path != "/login/oauth2/authorize" {
47+
if r.URL.Query().Get("redirected") != "" {
48+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
49+
Status: http.StatusInternalServerError,
50+
HideStatus: false,
51+
Title: "Oauth Redirect Loop",
52+
Description: "Oauth redirect loop detected.",
53+
RetryEnabled: false,
54+
DashboardURL: accessURL.String(),
55+
Warnings: nil,
56+
})
57+
return
58+
}
59+
60+
// Extract the form parameters for two reasons:
61+
// 1. We need the redirect URI to build the cancel URI.
62+
// 2. Since validation will run once the user clicks "allow", it is
63+
// better to validate now to avoid wasting the user's time clicking a
64+
// button that will just error anyway.
65+
params, errs, err := extractAuthorizeParams(r, app.CallbackURL)
66+
if err != nil {
67+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
68+
Status: http.StatusInternalServerError,
69+
HideStatus: false,
70+
Title: "Internal Server Error",
71+
Description: err.Error(),
72+
RetryEnabled: false,
73+
DashboardURL: accessURL.String(),
74+
Warnings: nil,
75+
})
76+
return
77+
}
78+
if len(errs) > 0 {
79+
errStr := make([]string, len(errs))
80+
for i, err := range errs {
81+
errStr[i] = err.Detail
82+
}
83+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
84+
Status: http.StatusBadRequest,
85+
HideStatus: false,
86+
Title: "Invalid Query Parameters",
87+
Description: "One or more query parameters are missing or invalid.",
88+
RetryEnabled: false,
89+
DashboardURL: accessURL.String(),
90+
Warnings: errStr,
91+
})
92+
return
93+
}
94+
95+
cancel := params.redirectURL
96+
cancelQuery := params.redirectURL.Query()
97+
cancelQuery.Add("error", "access_denied")
98+
cancel.RawQuery = cancelQuery.Encode()
99+
100+
redirect := r.URL
101+
vals := redirect.Query()
102+
vals.Add("redirected", "true")
103+
r.URL.RawQuery = vals.Encode()
104+
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
105+
AppIcon: app.Icon,
106+
AppName: app.Name,
107+
CancelURI: cancel.String(),
108+
RedirectURI: r.URL.String(),
109+
Username: ua.ActorName,
110+
})
111+
return
112+
}
113+
114+
next.ServeHTTP(rw, r)
115+
})
116+
}
117+
}

0 commit comments

Comments
 (0)
0