Skip to content

Commit 896e50a

Browse files
committed
Add OAuth2 auth, token, and revoke routes
1 parent 7812c5b commit 896e50a

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
@@ -167,3 +167,22 @@ func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.U
167167
}
168168
return nil
169169
}
170+
171+
type OAuth2ProviderGrantType string
172+
173+
const (
174+
OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code"
175+
)
176+
177+
// RevokeOAuth2ProviderApp completely revokes an app's access for the user.
178+
func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error {
179+
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/oauth2-provider/apps/%s/tokens", appID), nil)
180+
if err != nil {
181+
return err
182+
}
183+
defer res.Body.Close()
184+
if res.StatusCode != http.StatusNoContent {
185+
return ReadBodyAsError(res)
186+
}
187+
return nil
188+
}

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 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)
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