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

Skip to content

Commit 47bf488

Browse files
committed
Add OAuth2 auth, token, and revoke routes
1 parent 9547d68 commit 47bf488

File tree

7 files changed

+953
-1
lines changed

7 files changed

+953
-1
lines changed

codersdk/oauth2.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,22 @@ 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+
// RevokeOAuth2ProviderApp completely revokes an app's access for the user.
190+
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
191+
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/tokens", appID), nil)
192+
if err != nil {
193+
return err
194+
}
195+
defer res.Body.Close()
196+
if res.StatusCode != http.StatusNoContent {
197+
return ReadBodyAsError(res)
198+
}
199+
return nil
200+
}

enterprise/coderd/coderd.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,21 @@ 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+
apiKeyMiddlewareOptional,
171+
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
172+
)
173+
// Oauth2 linking routes do not make sense under the /api/v2 path.
174+
r.Route("/login", func(r chi.Router) {
175+
r.Route("/oauth2", func(r chi.Router) {
176+
r.Get("/authorize", api.postOAuth2ProviderAppAuthorize())
177+
r.Post("/tokens", api.postOAuth2ProviderAppToken())
178+
})
179+
})
180+
})
181+
167182
api.AGPL.APIHandler.Group(func(r chi.Router) {
168183
r.Get("/entitlements", api.serveEntitlements)
169184
// /regions overrides the AGPL /regions endpoint
@@ -334,6 +349,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
334349
r.Get("/", api.oAuth2ProviderApp)
335350
r.Put("/", api.putOAuth2ProviderApp)
336351
r.Delete("/", api.deleteOAuth2ProviderApp)
352+
r.Delete("/tokens", api.deleteOAuth2ProviderAppTokens)
337353

338354
r.Route("/secrets", func(r chi.Router) {
339355
r.Get("/", api.oAuth2ProviderAppSecrets)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package identityprovider
2+
3+
import (
4+
"crypto/sha256"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"golang.org/x/xerrors"
12+
13+
"github.com/coder/coder/v2/coderd/database"
14+
"github.com/coder/coder/v2/coderd/database/dbtime"
15+
"github.com/coder/coder/v2/coderd/httpapi"
16+
"github.com/coder/coder/v2/coderd/httpmw"
17+
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/coder/v2/cryptorand"
19+
)
20+
21+
/**
22+
* Authorize displays an HTML for authorizing an application when the user has
23+
* first been redirected to this path and generates a code and redirects to the
24+
* app's callback URL after the user clicks "allow" on that page.
25+
*/
26+
func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
27+
handler := func(rw http.ResponseWriter, r *http.Request) {
28+
ctx := r.Context()
29+
apiKey, ok := httpmw.APIKeyOptional(r)
30+
if !ok {
31+
// TODO: Should this be unauthorized? Or Forbidden?
32+
// This should redirect to a login page.
33+
httpapi.Forbidden(rw)
34+
return
35+
}
36+
37+
app := httpmw.OAuth2ProviderApp(r)
38+
39+
// TODO: @emyrk this should always work, maybe make callbackURL a *url.URL?
40+
callbackURL, _ := url.Parse(app.CallbackURL)
41+
42+
// TODO: Should validate these on the HTML page as well and show errors
43+
// there rather than wait until this endpoint to show them.
44+
p := httpapi.NewQueryParamParser()
45+
vals := r.URL.Query()
46+
p.Required("state", "response_type")
47+
state := p.String(vals, "", "state")
48+
scope := p.Strings(vals, []string{}, "scope")
49+
// Client_id is already parsed in the mw above.
50+
_ = p.String(vals, "", "client_id")
51+
redirectURL := p.URL(vals, callbackURL, "redirect_uri")
52+
responseType := p.String(vals, "", "response_type")
53+
// TODO: Redirected might exist but it should not cause validation errors.
54+
_ = p.String(vals, "", "redirected")
55+
p.ErrorExcessParams(vals)
56+
if len(p.Errors) > 0 {
57+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
58+
Message: "Invalid query params.",
59+
Validations: p.Errors,
60+
})
61+
return
62+
}
63+
64+
// TODO: @emyrk what other ones are there?
65+
if responseType != "code" {
66+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
67+
Message: "Invalid response type.",
68+
})
69+
return
70+
}
71+
72+
// TODO: @emyrk handle scope?
73+
_ = scope
74+
75+
if < 103CE span class=pl-s1>err := validateRedirectURL(app.CallbackURL, redirectURL.String()); err != nil {
76+
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
77+
Message: "Invalid redirect URL.",
78+
})
79+
return
80+
}
81+
// 40 characters matches the length of GitHub's client secrets.
82+
rawSecret, err := cryptorand.String(40)
83+
if err != nil {
84+
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
85+
Message: "Failed to generate OAuth2 app authorization code.",
86+
})
87+
return
88+
}
89+
hashed := sha256.Sum256([]byte(rawSecret))
90+
_, err = db.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
91+
ID: uuid.New(),
92+
CreatedAt: dbtime.Now(),
93+
// TODO: Configurable expiration?
94+
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
95+
HashedSecret: hashed[:],
96+
AppID: app.ID,
97+
UserID: apiKey.UserID,
98+
})
99+
if err != nil {
100+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
101+
Message: "Internal error insert OAuth2 authorization code.",
102+
Detail: err.Error(),
103+
})
104+
return
105+
}
106+
107+
newQuery := redirectURL.Query()
108+
newQuery.Add("code", rawSecret)
109+
newQuery.Add("state", state)
110+
redirectURL.RawQuery = newQuery.Encode()
111+
112+
http.Redirect(rw, r, redirectURL.String(), http.StatusTemporaryRedirect)
113+
}
114+
115+
// Always wrap with its custom mw.
116+
return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP
117+
}
118+
119+
// validateRedirectURL validates that the redirectURL is contained in baseURL.
120+
func validateRedirectURL(baseURL string, redirectURL string) error {
121+
base, err := url.Parse(baseURL)
122+
if err != nil {
123+
return err
124+
}
125+
126+
redirect, err := url.Parse(redirectURL)
127+
if err != nil {
128+
return err
129+
}
130+
// It can be a sub-directory but not a sub-domain, as we have apps on
131+
// sub-domains so it seems too dangerous.
132+
if redirect.Host != base.Host || !strings.HasPrefix(redirect.Path, base.Path) {
133+
return xerrors.New("invalid redirect URL")
134+
}
135+
return nil
136+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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: "Internal error deleting OAuth2 client secret.",
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: "Internal error deleting OAuth2 client secret.",
35+
Detail: err.Error(),
36+
})
37+
return
38+
}
39+
40+
app := httpmw.OAuth2ProviderApp(r)
41+
// If the request comes from outside, then we show the html allow page.
42+
// TODO: Skip this step if the user has already clicked allow before, and
43+
// we can just reuse the token.
44+
if originU.Hostname() != accessURL.Hostname() && refererU.Path != "/login/oauth2/authorize" {
45+
if r.URL.Query().Get("redirected") != "" {
46+
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
47+
Status: http.StatusInternalServerError,
48+
HideStatus: false,
49+
Title: "Oauth Redirect Loop",
50+
Description: "Oauth redirect loop detected.",
51+
RetryEnabled: false,
52+
DashboardURL: accessURL.String(),
53+
Warnings: nil,
54+
})
55+
return
56+
}
57+
58+
redirect := r.URL
59+
vals := redirect.Query()
60+
vals.Add("redirected", "true")
61+
r.URL.RawQuery = vals.Encode()
62+
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
63+
AppName: app.Name,
64+
Icon: app.Icon,
65+
RedirectURI: r.URL.String(),
66+
})
67+
return
68+
}
69+
70+
next.ServeHTTP(rw, r)
71+
})
72+
}
73+
}

0 commit comments

Comments
 (0)
0