Skip to content

Commit 6df8581

Browse files
committed
Add OAuth2 auth, token, and revoke routes
1 parent af37658 commit 6df8581

File tree

8 files changed

+1186
-4
lines changed

8 files changed

+1186
-4
lines changed

codersdk/oauth2.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,50 @@ 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+
//nolint:gocritic,revive // More cases will be added later.
191+
switch e {
192+
case OAuth2ProviderGrantTypeAuthorizationCode:
193+
return true
194+
}
195+
return false
196+
}
197+
198+
type OAuth2ProviderResponseType string
199+
200+
const (
201+
OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code"
202+
)
203+
204+
func (e OAuth2ProviderResponseType) Valid() bool {
205+
//nolint:gocritic,revive // More cases might be added later.
206+
switch e {
207+
case OAuth2ProviderResponseTypeCode:
208+
return true
209+
}
210+
return false
211+
}
212+
213+
// RevokeOAuth2ProviderApp completely revokes an app's access for the user.
214+
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
215+
res, err := c.Request(ctx, http.MethodDelete, "/login/oauth2/tokens", nil, func(r *http.Request) {
216+
q := r.URL.Query()
217+
q.Set("client_id", appID.String())
218+
r.URL.RawQuery = q.Encode()
219+
})
220+
if err != nil {
221+
return err
222+
}
223+
defer res.Body.Close()
224+
if res.StatusCode != http.StatusNoContent {
225+
return ReadBodyAsError(res)
226+
}
227+
return nil
228+
}

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: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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 page for authorizing an application when the user
66+
* has first been redirected to this path and generates a code and redirects to
67+
* the app's callback URL after the user clicks "allow" on that page, which is
68+
* detected via the origin and referer headers.
69+
*/
70+
func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
71+
handler := func(rw http.ResponseWriter, r *http.Request) {
72+
ctx := r.Context()
73+
apiKey := httpmw.APIKey(r)
74+
app := httpmw.OAuth2ProviderApp(r)
75+
76+
params, validationErrs, err := extractAuthorizeParams(r, app.CallbackURL)
77+
if err != nil {
78+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
79+
Message: "Failed to validate query parameters.",
80+
Detail: err.Error(),
81+
})
82+
return
83+
}
84+
if len(validationErrs) > 0 {
85+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
86+
Message: "Invalid query params.",
87+
Validations: validationErrs,
88+
})
89+
return
90+
}
91+
92+
// TODO: Ignoring scope for now, but should look into implementing.
93+
// 40 characters matches the length of GitHub's client secrets.
94+
rawCode, err := cryptorand.String(40)
95+
if err != nil {
96+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
97+
Message: "Failed to generate OAuth2 app authorization code.",
98+
})
99+
return
100+
}
101+
hashedCode := Hash(rawCode, app.ID)
102+
err = db.InTx(func(tx database.Store) error {
103+
// Delete any previous codes.
104+
err = tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
105+
AppID: app.ID,
106+
UserID: apiKey.UserID,
107+
})
108+
if err != nil && !errors.Is(err, sql.ErrNoRows) {
109+
return xerrors.Errorf("delete oauth2 app codes: %w", err)
110+
}
111+
112+
// Insert the new code.
113+
_, err = tx.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
114+
ID: uuid.New(),
115+
CreatedAt: dbtime.Now(),
116+
// TODO: Configurable expiration? Ten minutes matches GitHub.
117+
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
118+
HashedSecret: hashedCode[:],
119+
AppID: app.ID,
120+
UserID: apiKey.UserID,
121+
})
122+
if err != nil {
123+
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
124+
}
125+
126+
return nil
127+
}, nil)
128+
if err != nil {
129+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
130+
Message: "Failed to generate OAuth2 authorization code.",
131+
Detail: err.Error(),
132+
})
133+
return
134+
}
135+
136+
newQuery := params.redirectURL.Query()
137+
newQuery.Add("code", rawCode)
138+
newQuery.Add("state", params.state)
139+
params.redirectURL.RawQuery = newQuery.Encode()
140+
141+
http.Redirect(rw, r, params.redirectURL.String(), http.StatusTemporaryRedirect)
142+
}
143+
144+
// Always wrap with its custom mw.
145+
return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP
146+
}
147+
148+
// validateRedirectURL validates that the redirectURL is contained in baseURL.
149+
func validateRedirectURL(baseURL *url.URL, redirectURL string) error {
150+
redirect, err := url.Parse(redirectURL)
151+
if err != nil {
152+
return err
153+
}
154+
// It can be a sub-directory but not a sub-domain, as we have apps on
155+
// sub-domains so it seems too dangerous.
156+
if redirect.Host != baseURL.Host || !strings.HasPrefix(redirect.Path, baseURL.Path) {
157+
return xerrors.New("invalid redirect URL")
158+
}
159+
return nil
160+
}
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy