diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0ca1742a585ce..228bb52d8ec2c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1431,6 +1431,146 @@ const docTemplate = `{ } } }, + "/login/oauth2/authorize": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Enterprise" + ], + "summary": "OAuth2 authorization request.", + "operationId": "oauth2-authorization-request", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "A random unguessable string", + "name": "state", + "in": "query", + "required": true + }, + { + "enum": [ + "code" + ], + "type": "string", + "description": "Response type", + "name": "response_type", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Redirect here after authorization", + "name": "redirect_uri", + "in": "query" + }, + { + "type": "string", + "description": "Token scopes (currently ignored)", + "name": "scope", + "in": "query" + } + ], + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/login/oauth2/tokens": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "OAuth2 token exchange.", + "operationId": "oauth2-token-exchange", + "parameters": [ + { + "type": "string", + "description": "Client ID, required if grant_type=authorization_code", + "name": "client_id", + "in": "formData" + }, + { + "type": "string", + "description": "Client secret, required if grant_type=authorization_code", + "name": "client_secret", + "in": "formData" + }, + { + "type": "string", + "description": "Authorization code, required if grant_type=authorization_code", + "name": "code", + "in": "formData" + }, + { + "type": "string", + "description": "Refresh token, required if grant_type=refresh_token", + "name": "refresh_token", + "in": "formData" + }, + { + "enum": [ + "authorization_code", + "refresh_token" + ], + "type": "string", + "description": "Grant type", + "name": "grant_type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oauth2.Token" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Enterprise" + ], + "summary": "Delete OAuth2 application tokens.", + "operationId": "delete-oauth2-application-tokens", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -1446,6 +1586,14 @@ const docTemplate = `{ ], "summary": "Get OAuth2 applications.", "operationId": "get-oauth2-applications", + "parameters": [ + { + "type": "string", + "description": "Filter by applications authorized for a user", + "name": "user_id", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -13663,6 +13811,27 @@ const docTemplate = `{ } } }, + "oauth2.Token": { + "type": "object", + "properties": { + "access_token": { + "description": "AccessToken is the token that authorizes and authenticates\nthe requests.", + "type": "string" + }, + "expiry": { + "description": "Expiry is the optional expiration time of the access token.\n\nIf zero, TokenSource implementations will reuse the same\ntoken forever and RefreshToken or equivalent\nmechanisms for that TokenSource will not be used.", + "type": "string" + }, + "refresh_token": { + "description": "RefreshToken is a token that's used by the application\n(as opposed to the user) to refresh the access token\nif it expires.", + "type": "string" + }, + "token_type": { + "description": "TokenType is the type of token.\nThe Type method returns either this or \"Bearer\", the default.", + "type": "string" + } + } + }, "tailcfg.DERPHomeParams": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 3523b478f0d1b..25a25f0f7b8db 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1239,6 +1239,133 @@ } } }, + "/login/oauth2/authorize": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Enterprise"], + "summary": "OAuth2 authorization request.", + "operationId": "oauth2-authorization-request", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "A random unguessable string", + "name": "state", + "in": "query", + "required": true + }, + { + "enum": ["code"], + "type": "string", + "description": "Response type", + "name": "response_type", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Redirect here after authorization", + "name": "redirect_uri", + "in": "query" + }, + { + "type": "string", + "description": "Token scopes (currently ignored)", + "name": "scope", + "in": "query" + } + ], + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/login/oauth2/tokens": { + "post": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "OAuth2 token exchange.", + "operationId": "oauth2-token-exchange", + "parameters": [ + { + "type": "string", + "description": "Client ID, required if grant_type=authorization_code", + "name": "client_id", + "in": "formData" + }, + { + "type": "string", + "description": "Client secret, required if grant_type=authorization_code", + "name": "client_secret", + "in": "formData" + }, + { + "type": "string", + "description": "Authorization code, required if grant_type=authorization_code", + "name": "code", + "in": "formData" + }, + { + "type": "string", + "description": "Refresh token, required if grant_type=refresh_token", + "name": "refresh_token", + "in": "formData" + }, + { + "enum": ["authorization_code", "refresh_token"], + "type": "string", + "description": "Grant type", + "name": "grant_type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/oauth2.Token" + } + } + } + }, + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Enterprise"], + "summary": "Delete OAuth2 application tokens.", + "operationId": "delete-oauth2-application-tokens", + "parameters": [ + { + "type": "string", + "description": "Client ID", + "name": "client_id", + "in": "query", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -1250,6 +1377,14 @@ "tags": ["Enterprise"], "summary": "Get OAuth2 applications.", "operationId": "get-oauth2-applications", + "parameters": [ + { + "type": "string", + "description": "Filter by applications authorized for a user", + "name": "user_id", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -12460,6 +12595,27 @@ } } }, + "oauth2.Token": { + "type": "object", + "properties": { + "access_token": { + "description": "AccessToken is the token that authorizes and authenticates\nthe requests.", + "type": "string" + }, + "expiry": { + "description": "Expiry is the optional expiration time of the access token.\n\nIf zero, TokenSource implementations will reuse the same\ntoken forever and RefreshToken or equivalent\nmechanisms for that TokenSource will not be used.", + "type": "string" + }, + "refresh_token": { + "description": "RefreshToken is a token that's used by the application\n(as opposed to the user) to refresh the access token\nif it expires.", + "type": "string" + }, + "token_type": { + "description": "TokenType is the type of token.\nThe Type method returns either this or \"Bearer\", the default.", + "type": "string" + } + } + }, "tailcfg.DERPHomeParams": { "type": "object", "properties": { diff --git a/coderd/coderdtest/oidctest/helper.go b/coderd/coderdtest/oidctest/helper.go index abf29d4fa2b46..beb1243e2ce74 100644 --- a/coderd/coderdtest/oidctest/helper.go +++ b/coderd/coderdtest/oidctest/helper.go @@ -1,14 +1,17 @@ package oidctest import ( + "context" "database/sql" "encoding/json" "net/http" + "net/url" "testing" "time" "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -114,3 +117,52 @@ func (h *LoginHelper) ForceRefresh(t *testing.T, db database.Store, user *coders _, err := user.User(testutil.Context(t, testutil.WaitShort), "me") require.NoError(t, err, "user must be able to be fetched") } + +// OAuth2GetCode emulates a user clicking "allow" on the IDP page. When doing +// unit tests, it's easier to skip this step sometimes. It does make an actual +// request to the IDP, so it should be equivalent to doing this "manually" with +// actual requests. +func OAuth2GetCode(rawAuthURL string, doRequest func(req *http.Request) (*http.Response, error)) (string, error) { + authURL, err := url.Parse(rawAuthURL) + if err != nil { + return "", xerrors.Errorf("failed to parse auth URL: %w", err) + } + + r, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawAuthURL, nil) + if err != nil { + return "", xerrors.Errorf("failed to create auth request: %w", err) + } + + expCode := http.StatusTemporaryRedirect + resp, err := doRequest(r) + if err != nil { + return "", xerrors.Errorf("request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != expCode { + return "", codersdk.ReadBodyAsError(resp) + } + + to := resp.Header.Get("Location") + if to == "" { + return "", xerrors.Errorf("expected redirect location") + } + + toURL, err := url.Parse(to) + if err != nil { + return "", xerrors.Errorf("failed to parse redirect location: %w", err) + } + + code := toURL.Query().Get("code") + if code == "" { + return "", xerrors.Errorf("expected code in redirect location") + } + + state := authURL.Query().Get("state") + newState := toURL.Query().Get("state") + if newState != state { + return "", xerrors.Errorf("expected state %q, got %q", state, newState) + } + return code, nil +} diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index cc0fe97434ee1..a185332e87335 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -534,37 +534,18 @@ func (*FakeIDP) DeviceLogin(t testing.TB, client *codersdk.Client, externalAuthI // unit tests, it's easier to skip this step sometimes. It does make an actual // request to the IDP, so it should be equivalent to doing this "manually" with // actual requests. -func (f *FakeIDP) CreateAuthCode(t testing.TB, state string, opts ...func(r *http.Request)) string { +func (f *FakeIDP) CreateAuthCode(t testing.TB, state string) string { // We need to store some claims, because this is also an OIDC provider, and // it expects some claims to be present. f.stateToIDTokenClaims.Store(state, jwt.MapClaims{}) - u := f.cfg.AuthCodeURL(state) - r, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u, nil) - require.NoError(t, err, "failed to create auth request") - - for _, opt := range opts { - opt(r) - } - - rw := httptest.NewRecorder() - f.handler.ServeHTTP(rw, r) - resp := rw.Result() - defer resp.Body.Close() - - require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode, "expected redirect") - to := resp.Header.Get("Location") - require.NotEmpty(t, to, "expected redirect location") - - toURL, err := url.Parse(to) - require.NoError(t, err, "failed to parse redirect location") - - code := toURL.Query().Get("code") - require.NotEmpty(t, code, "expected code in redirect location") - - newState := toURL.Query().Get("state") - require.Equal(t, state, newState, "expected state to match") - + code, err := OAuth2GetCode(f.cfg.AuthCodeURL(state), func(req *http.Request) (*http.Response, error) { + rw := httptest.NewRecorder() + f.handler.ServeHTTP(rw, req) + resp := rw.Result() + return resp, nil + }) + require.NoError(t, err, "failed to get auth code") return code } @@ -1071,7 +1052,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { f.logger.Info(r.Context(), "http call device auth") p := httpapi.NewQueryParamParser() - p.Required("client_id") + p.RequiredNotEmpty("client_id") clientID := p.String(r.URL.Query(), "", "client_id") _ = p.String(r.URL.Query(), "", "scopes") if len(p.Errors) > 0 { diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 9b7daf2310900..822cfea22de15 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -23,16 +23,16 @@ type QueryParamParser struct { // Parsed is a map of all query params that were parsed. This is useful // for checking if extra query params were passed in. Parsed map[string]bool - // RequiredParams is a map of all query params that are required. This is useful + // RequiredNotEmptyParams is a map of all query params that are required. This is useful // for forcing a value to be provided. - RequiredParams map[string]bool + RequiredNotEmptyParams map[string]bool } func NewQueryParamParser() *QueryParamParser { return &QueryParamParser{ - Errors: []codersdk.ValidationError{}, - Parsed: map[string]bool{}, - RequiredParams: map[string]bool{}, + Errors: []codersdk.ValidationError{}, + Parsed: map[string]bool{}, + RequiredNotEmptyParams: map[string]bool{}, } } @@ -90,8 +90,10 @@ func (p *QueryParamParser) Boolean(vals url.Values, def bool, queryParam string) return v } -func (p *QueryParamParser) Required(queryParam string) *QueryParamParser { - p.RequiredParams[queryParam] = true +func (p *QueryParamParser) RequiredNotEmpty(queryParam ...string) *QueryParamParser { + for _, q := range queryParam { + p.RequiredNotEmptyParams[q] = true + } return p } @@ -121,6 +123,27 @@ func (p *QueryParamParser) UUIDs(vals url.Values, def []uuid.UUID, queryParam st }) } +func (p *QueryParamParser) RedirectURL(vals url.Values, base *url.URL, queryParam string) *url.URL { + v, err := parseQueryParam(p, vals, url.Parse, base, queryParam) + if err != nil { + p.Errors = append(p.Errors, codersdk.ValidationError{ + Field: queryParam, + Detail: fmt.Sprintf("Query param %q must be a valid url: %s", queryParam, err.Error()), + }) + } + + // It can be a sub-directory but not a sub-domain, as we have apps on + // sub-domains and that seems too dangerous. + if v.Host != base.Host || !strings.HasPrefix(v.Path, base.Path) { + p.Errors = append(p.Errors, codersdk.ValidationError{ + Field: queryParam, + Detail: fmt.Sprintf("Query param %q must be a subset of %s", queryParam, base), + }) + } + + return v +} + func (p *QueryParamParser) Time(vals url.Values, def time.Time, queryParam, layout string) time.Time { return p.timeWithMutate(vals, def, queryParam, layout, nil) } @@ -233,10 +256,10 @@ func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T, func parseQueryParam[T any](parser *QueryParamParser, vals url.Values, parse func(v string) (T, error), def T, queryParam string) (T, error) { parser.addParsed(queryParam) // If the query param is required and not present, return an error. - if parser.RequiredParams[queryParam] && (!vals.Has(queryParam)) { + if parser.RequiredNotEmptyParams[queryParam] && (!vals.Has(queryParam) || vals.Get(queryParam) == "") { parser.Errors = append(parser.Errors, codersdk.ValidationError{ Field: queryParam, - Detail: fmt.Sprintf("Query param %q is required", queryParam), + Detail: fmt.Sprintf("Query param %q is required and cannot be empty", queryParam), }) return def, nil } diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index f919b478dfd78..b9773bfa252ab 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -320,9 +320,14 @@ func TestParseQueryParams(t *testing.T) { t.Parallel() parser := httpapi.NewQueryParamParser() - parser.Required("test_value") + parser.RequiredNotEmpty("test_value") parser.UUID(url.Values{}, uuid.New(), "test_value") require.Len(t, parser.Errors, 1) + + parser = httpapi.NewQueryParamParser() + parser.RequiredNotEmpty("test_value") + parser.String(url.Values{"test_value": {""}}, "", "test_value") + require.Len(t, parser.Errors, 1) }) } diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index dbb763bc9de3e..98baaae4c4f57 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -6,6 +6,8 @@ import ( "net/http" "reflect" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" "golang.org/x/oauth2" "github.com/coder/coder/v2/coderd/database" @@ -194,9 +196,44 @@ func ExtractOAuth2ProviderApp(db database.Store) func(http.Handler) http.Handler return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - appID, ok := ParseUUIDParam(rw, r, "app") - if !ok { - return + + // App can come from a URL param, query param, or form value. + paramID := "app" + var appID uuid.UUID + if chi.URLParam(r, paramID) != "" { + var ok bool + appID, ok = ParseUUIDParam(rw, r, "app") + if !ok { + return + } + } else { + // If not provided by the url, then it is provided according to the + // oauth 2 spec. This can occur with query params, or in the body as + // form parameters. + // This also depends on if you are doing a POST (tokens) or GET (authorize). + paramAppID := r.URL.Query().Get("client_id") + if paramAppID == "" { + // Check the form params! + if r.ParseForm() == nil { + paramAppID = r.Form.Get("client_id") + } + } + if paramAppID == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Missing OAuth2 client ID.", + }) + return + } + + var err error + appID, err = uuid.Parse(paramAppID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid OAuth2 client ID.", + Detail: err.Error(), + }) + return + } } app, err := db.GetOAuth2ProviderAppByID(ctx, appID) diff --git a/coderd/insights.go b/coderd/insights.go index 4f29e2ef85d9c..214eae5510d4c 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -72,8 +72,8 @@ func (api *API) insightsUserActivity(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() p := httpapi.NewQueryParamParser(). - Required("start_time"). - Required("end_time") + RequiredNotEmpty("start_time"). + RequiredNotEmpty("end_time") vals := r.URL.Query() var ( // The QueryParamParser does not preserve timezone, so we need @@ -161,8 +161,8 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() p := httpapi.NewQueryParamParser(). - Required("start_time"). - Required("end_time") + RequiredNotEmpty("start_time"). + RequiredNotEmpty("end_time") vals := r.URL.Query() var ( // The QueryParamParser does not preserve timezone, so we need @@ -253,8 +253,8 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() p := httpapi.NewQueryParamParser(). - Required("start_time"). - Required("end_time") + RequiredNotEmpty("start_time"). + RequiredNotEmpty("end_time") vals := r.URL.Query() var ( // The QueryParamParser does not preserve timezone, so we need diff --git a/coderd/users.go b/coderd/users.go index ca757ed80436f..4abacd5c27b07 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -583,7 +583,7 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) { func (api *API) userAutofillParameters(rw http.ResponseWriter, r *http.Request) { user := httpmw.UserParam(r) - p := httpapi.NewQueryParamParser().Required("template_id") + p := httpapi.NewQueryParamParser().RequiredNotEmpty("template_id") templateID := p.UUID(r.URL.Query(), uuid.UUID{}, "template_id") p.ErrorExcessParams(r.URL.Query()) if len(p.Errors) > 0 { diff --git a/coderd/workspaceapps/proxy.go b/coderd/workspaceapps/proxy.go index 984cfc62a3f99..70d8a64efa429 100644 --- a/coderd/workspaceapps/proxy.go +++ b/coderd/workspaceapps/proxy.go @@ -636,7 +636,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { values := r.URL.Query() parser := httpapi.NewQueryParamParser() - reconnect := parser.Required("reconnect").UUID(values, uuid.New(), "reconnect") + reconnect := parser.RequiredNotEmpty("reconnect").UUID(values, uuid.New(), "reconnect") height := parser.UInt(values, 80, "height") width := parser.UInt(values, 80, "width") if len(parser.Errors) > 0 { diff --git a/codersdk/oauth2.go b/codersdk/oauth2.go index 318743959d5dc..d4d20c26b157b 100644 --- a/codersdk/oauth2.go +++ b/codersdk/oauth2.go @@ -28,10 +28,21 @@ type OAuth2AppEndpoints struct { DeviceAuth string `json:"device_authorization"` } +type OAuth2ProviderAppFilter struct { + UserID uuid.UUID `json:"user_id,omitempty" format:"uuid"` +} + // OAuth2ProviderApps returns the applications configured to authenticate using // Coder as an OAuth2 provider. -func (c *Client) OAuth2ProviderApps(ctx context.Context) ([]OAuth2ProviderApp, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil) +func (c *Client) OAuth2ProviderApps(ctx context.Context, filter OAuth2ProviderAppFilter) ([]OAuth2ProviderApp, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/oauth2-provider/apps", nil, + func(r *http.Request) { + if filter.UserID != uuid.Nil { + q := r.URL.Query() + q.Set("user_id", filter.UserID.String()) + r.URL.RawQuery = q.Encode() + } + }) if err != nil { return []OAuth2ProviderApp{}, err } @@ -168,3 +179,51 @@ func (c *Client) DeleteOAuth2ProviderAppSecret(ctx context.Context, appID uuid.U } return nil } + +type OAuth2ProviderGrantType string + +const ( + OAuth2ProviderGrantTypeAuthorizationCode OAuth2ProviderGrantType = "authorization_code" + OAuth2ProviderGrantTypeRefreshToken OAuth2ProviderGrantType = "refresh_token" +) + +func (e OAuth2ProviderGrantType) Valid() bool { + switch e { + case OAuth2ProviderGrantTypeAuthorizationCode, OAuth2ProviderGrantTypeRefreshToken: + return true + } + return false +} + +type OAuth2ProviderResponseType string + +const ( + OAuth2ProviderResponseTypeCode OAuth2ProviderResponseType = "code" +) + +func (e OAuth2ProviderResponseType) Valid() bool { + //nolint:gocritic,revive // More cases might be added later. + switch e { + case OAuth2ProviderResponseTypeCode: + return true + } + return false +} + +// RevokeOAuth2ProviderApp completely revokes an app's access for the +// authenticated user. +func (c *Client) RevokeOAuth2ProviderApp(ctx context.Context, appID uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, "/login/oauth2/tokens", nil, func(r *http.Request) { + q := r.URL.Query() + q.Set("client_id", appID.String()) + r.URL.RawQuery = q.Encode() + }) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index cb100f346f17b..3e80637666a82 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -534,6 +534,127 @@ curl -X DELETE http://coder-server:8080/api/v2/licenses/{id} \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## OAuth2 authorization request. + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/login/oauth2/authorize?client_id=string&state=string&response_type=code \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /login/oauth2/authorize` + +### Parameters + +| Name | In | Type | Required | Description | +| --------------- | ----- | ------ | -------- | --------------------------------- | +| `client_id` | query | string | true | Client ID | +| `state` | query | string | true | A random unguessable string | +| `response_type` | query | string | true | Response type | +| `redirect_uri` | query | string | false | Redirect here after authorization | +| `scope` | query | string | false | Token scopes (currently ignored) | + +#### Enumerated Values + +| Parameter | Value | +| --------------- | ------ | +| `response_type` | `code` | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ---------------------------------------------------------- | ----------- | ------ | +| 302 | [Found](https://tools.ietf.org/html/rfc7231#section-6.4.3) | Found | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## OAuth2 token exchange. + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/login/oauth2/tokens \ + -H 'Accept: application/json' +``` + +`POST /login/oauth2/tokens` + +> Body parameter + +```yaml +client_id: string +client_secret: string +code: string +refresh_token: string +grant_type: authorization_code +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------------- | ---- | ------ | -------- | ------------------------------------------------------------- | +| `body` | body | object | false | | +| `» client_id` | body | string | false | Client ID, required if grant_type=authorization_code | +| `» client_secret` | body | string | false | Client secret, required if grant_type=authorization_code | +| `» code` | body | string | false | Authorization code, required if grant_type=authorization_code | +| `» refresh_token` | body | string | false | Refresh token, required if grant_type=refresh_token | +| `» grant_type` | body | string | true | Grant type | + +#### Enumerated Values + +| Parameter | Value | +| -------------- | -------------------- | +| `» grant_type` | `authorization_code` | +| `» grant_type` | `refresh_token` | + +### Example responses + +> 200 Response + +```json +{ + "access_token": "string", + "expiry": "string", + "refresh_token": "string", + "token_type": "string" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [oauth2.Token](schemas.md#oauth2token) | + +## Delete OAuth2 application tokens. + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/login/oauth2/tokens?client_id=string \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /login/oauth2/tokens` + +### Parameters + +| Name | In | Type | Required | Description | +| ----------- | ----- | ------ | -------- | ----------- | +| `client_id` | query | string | true | Client ID | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ----------- | ------ | +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get OAuth2 applications. ### Code samples @@ -547,6 +668,12 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \ `GET /oauth2-provider/apps` +### Parameters + +| Name | In | Type | Required | Description | +| --------- | ----- | ------ | -------- | -------------------------------------------- | +| `user_id` | query | string | false | Filter by applications authorized for a user | + ### Example responses > 200 Response diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 04b3a1ce6dad6..56241f8b80c42 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -8594,6 +8594,27 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `udp` | boolean | false | | a UDP STUN round trip completed | | `upnP` | string | false | | Upnp is whether UPnP appears present on the LAN. Empty means not checked. | +## oauth2.Token + +```json +{ + "access_token": "string", + "expiry": "string", + "refresh_token": "string", + "token_type": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `access_token` | string | false | | Access token is the token that authorizes and authenticates the requests. | +| `expiry` | string | false | | Expiry is the optional expiration time of the access token. | +| If zero, TokenSource implementations will reuse the same token forever and RefreshToken or equivalent mechanisms for that TokenSource will not be used. | +| `refresh_token` | string | false | | Refresh token is a token that's used by the application (as opposed to the user) to refresh the access token if it expires. | +| `token_type` | string | false | | Token type is the type of token. The Type method returns either this or "Bearer", the default. | + ## tailcfg.DERPHomeParams ```json diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 25bf7b971f977..42231016a3a43 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -165,6 +165,28 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { return nil, xerrors.Errorf("failed to get deployment ID: %w", err) } + api.AGPL.RootHandler.Group(func(r chi.Router) { + r.Use( + api.oAuth2ProviderMiddleware, + // Fetch the app as system because in the /tokens route there will be no + // authenticated user. + httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)), + ) + // Oauth2 linking routes do not make sense under the /api/v2 path. + r.Route("/login", func(r chi.Router) { + r.Route("/oauth2", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/authorize", api.postOAuth2ProviderAppAuthorize()) + r.Delete("/tokens", api.deleteOAuth2ProviderAppTokens()) + }) + // The /tokens endpoint will be called from an unauthorized client so we + // cannot require an API key. + r.Post("/tokens", api.postOAuth2ProviderAppToken()) + }) + }) + }) + api.AGPL.APIHandler.Group(func(r chi.Router) { r.Get("/entitlements", api.serveEntitlements) // /regions overrides the AGPL /regions endpoint diff --git a/enterprise/coderd/identityprovider/authorize.go b/enterprise/coderd/identityprovider/authorize.go new file mode 100644 index 0000000000000..f41a0842e9dde --- /dev/null +++ b/enterprise/coderd/identityprovider/authorize.go @@ -0,0 +1,140 @@ +package identityprovider + +import ( + "database/sql" + "errors" + "net/http" + "net/url" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" +) + +type authorizeParams struct { + clientID string + redirectURL *url.URL + responseType codersdk.OAuth2ProviderResponseType + scope []string + state string +} + +func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizeParams, []codersdk.ValidationError, error) { + p := httpapi.NewQueryParamParser() + vals := r.URL.Query() + + p.RequiredNotEmpty("state", "response_type", "client_id") + + params := authorizeParams{ + clientID: p.String(vals, "", "client_id"), + redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"), + responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]), + scope: p.Strings(vals, []string{}, "scope"), + state: p.String(vals, "", "state"), + } + + // We add "redirected" when coming from the authorize page. + _ = p.String(vals, "", "redirected") + + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + return authorizeParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors) + } + return params, nil, nil +} + +// Authorize displays an HTML page for authorizing an application when the user +// has first been redirected to this path and generates a code and redirects to +// the app's callback URL after the user clicks "allow" on that page, which is +// detected via the origin and referer headers. +func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc { + handler := func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + app := httpmw.OAuth2ProviderApp(r) + + callbackURL, err := url.Parse(app.CallbackURL) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate query parameters.", + Detail: err.Error(), + }) + return + } + + params, validationErrs, err := extractAuthorizeParams(r, callbackURL) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query params.", + Detail: err.Error(), + Validations: validationErrs, + }) + return + } + + // TODO: Ignoring scope for now, but should look into implementing. + code, err := GenerateSecret() + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to generate OAuth2 app authorization code.", + }) + return + } + err = db.InTx(func(tx database.Store) error { + // Delete any previous codes. + err = tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{ + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete oauth2 app codes: %w", err) + } + + // Insert the new code. + _, err = tx.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + // TODO: Configurable expiration? Ten minutes matches GitHub. + // This timeout is only for the code that will be exchanged for the + // access token, not the access token itself. It does not need to be + // long-lived because normally it will be exchanged immediately after it + // is received. If the application does wait before exchanging the + // token (for example suppose they ask the user to confirm and the user + // has left) then they can just retry immediately and get a new code. + ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute), + SecretPrefix: []byte(code.Prefix), + HashedSecret: []byte(code.Hashed), + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil { + return xerrors.Errorf("insert oauth2 authorization code: %w", err) + } + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to generate OAuth2 authorization code.", + Detail: err.Error(), + }) + return + } + + newQuery := params.redirectURL.Query() + newQuery.Add("code", code.Formatted) + newQuery.Add("state", params.state) + params.redirectURL.RawQuery = newQuery.Encode() + + http.Redirect(rw, r, params.redirectURL.String(), http.StatusTemporaryRedirect) + } + + // Always wrap with its custom mw. + return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP +} diff --git a/enterprise/coderd/identityprovider/middleware.go b/enterprise/coderd/identityprovider/middleware.go new file mode 100644 index 0000000000000..640ea8652e136 --- /dev/null +++ b/enterprise/coderd/identityprovider/middleware.go @@ -0,0 +1,149 @@ +package identityprovider + +import ( + "net/http" + "net/url" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/site" +) + +// authorizeMW serves to remove some code from the primary authorize handler. +// It decides when to show the html allow page, and when to just continue. +func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + origin := r.Header.Get(httpmw.OriginHeader) + originU, err := url.Parse(origin) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid origin header.", + Detail: err.Error(), + }) + return + } + + referer := r.Referer() + refererU, err := url.Parse(referer) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid referer header.", + Detail: err.Error(), + }) + return + } + + app := httpmw.OAuth2ProviderApp(r) + ua := httpmw.UserAuthorization(r) + + // url.Parse() allows empty URLs, which is fine because the origin is not + // always set by browsers (or other tools like cURL). If the origin does + // exist, we will make sure it matches. We require `referer` to be set at + // a minimum in order to detect whether "allow" has been pressed, however. + cameFromSelf := (origin == "" || originU.Hostname() == accessURL.Hostname()) && + refererU.Hostname() == accessURL.Hostname() && + refererU.Path == "/login/oauth2/authorize" + + // If we were redirected here from this same page it means the user + // pressed the allow button so defer to the authorize handler which + // generates the code, otherwise show the HTML allow page. + // TODO: Skip this step if the user has already clicked allow before, and + // we can just reuse the token. + if cameFromSelf { + next.ServeHTTP(rw, r) + return + } + + // TODO: For now only browser-based auth flow is officially supported but + // in a future PR we should support a cURL-based flow where we output text + // instead of HTML. + if r.URL.Query().Get("redirected") != "" { + // When the user first comes into the page, referer might be blank which + // is OK. But if they click "allow" and their browser has *still* not + // sent the referer header, we have no way of telling whether they + // actually clicked the button. "Redirected" means they *might* have + // pressed it, but it could also mean an app added it for them as part + // of their redirect, so we cannot use it as a replacement for referer + // and the best we can do is error. + if referer == "" { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + HideStatus: false, + Title: "Referer header missing", + Description: "We cannot continue authorization because your client has not sent the referer header.", + RetryEnabled: false, + DashboardURL: accessURL.String(), + Warnings: nil, + }) + return + } + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + HideStatus: false, + Title: "Oauth Redirect Loop", + Description: "Oauth redirect loop detected.", + RetryEnabled: false, + DashboardURL: accessURL.String(), + Warnings: nil, + }) + return + } + + callbackURL, err := url.Parse(app.CallbackURL) + if err != nil { + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusInternalServerError, + HideStatus: false, + Title: "Internal Server Error", + Description: err.Error(), + RetryEnabled: false, + DashboardURL: accessURL.String(), + Warnings: nil, + }) + return + } + + // Extract the form parameters for two reasons: + // 1. We need the redirect URI to build the cancel URI. + // 2. Since validation will run once the user clicks "allow", it is + // better to validate now to avoid wasting the user's time clicking a + // button that will just error anyway. + params, validationErrs, err := extractAuthorizeParams(r, callbackURL) + if err != nil { + errStr := make([]string, len(validationErrs)) + for i, err := range validationErrs { + errStr[i] = err.Detail + } + site.RenderStaticErrorPage(rw, r, site.ErrorPageData{ + Status: http.StatusBadRequest, + HideStatus: false, + Title: "Invalid Query Parameters", + Description: "One or more query parameters are missing or invalid.", + RetryEnabled: false, + DashboardURL: accessURL.String(), + Warnings: errStr, + }) + return + } + + cancel := params.redirectURL + cancelQuery := params.redirectURL.Query() + cancelQuery.Add("error", "access_denied") + cancel.RawQuery = cancelQuery.Encode() + + redirect := r.URL + vals := redirect.Query() + vals.Add("redirected", "true") // For loop detection. + r.URL.RawQuery = vals.Encode() + site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{ + AppIcon: app.Icon, + AppName: app.Name, + CancelURI: cancel.String(), + RedirectURI: r.URL.String(), + Username: ua.ActorName, + }) + }) + } +} diff --git a/enterprise/coderd/identityprovider/revoke.go b/enterprise/coderd/identityprovider/revoke.go new file mode 100644 index 0000000000000..cddc150bbe364 --- /dev/null +++ b/enterprise/coderd/identityprovider/revoke.go @@ -0,0 +1,44 @@ +package identityprovider + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" +) + +func RevokeApp(db database.Store) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + app := httpmw.OAuth2ProviderApp(r) + + err := db.InTx(func(tx database.Store) error { + err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{ + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{ + AppID: app.ID, + UserID: apiKey.UserID, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + return nil + }, nil) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusNoContent, nil) + } +} diff --git a/enterprise/coderd/identityprovider/secrets.go b/enterprise/coderd/identityprovider/secrets.go new file mode 100644 index 0000000000000..72524b3d2a077 --- /dev/null +++ b/enterprise/coderd/identityprovider/secrets.go @@ -0,0 +1,77 @@ +package identityprovider + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/cryptorand" +) + +type OAuth2ProviderAppSecret struct { + // Formatted contains the secret. This value is owned by the client, not the + // server. It is formatted to include the prefix. + Formatted string + // Prefix is the ID of this secret owned by the server. When a client uses a + // secret, this is the matching string to do a lookup on the hashed value. We + // cannot use the hashed value directly because the server does not store the + // salt. + Prefix string + // Hashed is the server stored hash(secret,salt,...). Used for verifying a + // secret. + Hashed string +} + +// GenerateSecret generates a secret to be used as a client secret, refresh +// token, or authorization code. +func GenerateSecret() (OAuth2ProviderAppSecret, error) { + // 40 characters matches the length of GitHub's client secrets. + secret, err := cryptorand.String(40) + if err != nil { + return OAuth2ProviderAppSecret{}, err + } + + // This ID is prefixed to the secret so it can be used to look up the secret + // when the user provides it, since we cannot just re-hash it to match as we + // will not have the salt. + prefix, err := cryptorand.String(10) + if err != nil { + return OAuth2ProviderAppSecret{}, err + } + + hashed, err := userpassword.Hash(secret) + if err != nil { + return OAuth2ProviderAppSecret{}, err + } + + return OAuth2ProviderAppSecret{ + Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret), + Prefix: prefix, + Hashed: hashed, + }, nil +} + +type parsedSecret struct { + prefix string + secret string +} + +// parseSecret extracts the ID and original secret from a secret. +func parseSecret(secret string) (parsedSecret, error) { + parts := strings.Split(secret, "_") + if len(parts) != 3 { + return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts)) + } + if parts[0] != "coder" { + return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0]) + } + if len(parts[1]) == 0 { + return parsedSecret{}, xerrors.Errorf("prefix is invalid") + } + if len(parts[2]) == 0 { + return parsedSecret{}, xerrors.Errorf("invalid") + } + return parsedSecret{parts[1], parts[2]}, nil +} diff --git a/enterprise/coderd/identityprovider/tokens.go b/enterprise/coderd/identityprovider/tokens.go new file mode 100644 index 0000000000000..0673eb7d1af7c --- /dev/null +++ b/enterprise/coderd/identityprovider/tokens.go @@ -0,0 +1,378 @@ +package identityprovider + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/google/uuid" + "golang.org/x/oauth2" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/codersdk" +) + +var ( + // errBadSecret means the user provided a bad secret. + errBadSecret = xerrors.New("Invalid client secret") + // errBadCode means the user provided a bad code. + errBadCode = xerrors.New("Invalid code") + // errBadToken means the user provided a bad token. + errBadToken = xerrors.New("Invalid token") +) + +type tokenParams struct { + clientID string + clientSecret string + code string + grantType codersdk.OAuth2ProviderGrantType + redirectURL *url.URL + refreshToken string +} + +func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) { + p := httpapi.NewQueryParamParser() + err := r.ParseForm() + if err != nil { + return tokenParams{}, nil, xerrors.Errorf("parse form: %w", err) + } + + vals := r.Form + p.RequiredNotEmpty("grant_type") + grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType]) + switch grantType { + case codersdk.OAuth2ProviderGrantTypeRefreshToken: + p.RequiredNotEmpty("refresh_token") + case codersdk.OAuth2ProviderGrantTypeAuthorizationCode: + p.RequiredNotEmpty("client_secret", "client_id", "code") + } + + params := tokenParams{ + clientID: p.String(vals, "", "client_id"), + clientSecret: p.String(vals, "", "client_secret"), + code: p.String(vals, "", "code"), + grantType: grantType, + redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"), + refreshToken: p.String(vals, "", "refresh_token"), + } + + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + return tokenParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors) + } + return params, nil, nil +} + +func Tokens(db database.Store, defaultLifetime time.Duration) http.HandlerFunc { + return func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + app := httpmw.OAuth2ProviderApp(r) + + callbackURL, err := url.Parse(app.CallbackURL) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to validate form values.", + Detail: err.Error(), + }) + return + } + + params, validationErrs, err := extractTokenParams(r, callbackURL) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid query params.", + Detail: err.Error(), + Validations: validationErrs, + }) + return + } + + var token oauth2.Token + //nolint:gocritic,revive // More cases will be added later. + switch params.grantType { + // TODO: Client creds, device code. + case codersdk.OAuth2ProviderGrantTypeRefreshToken: + token, err = refreshTokenGrant(ctx, db, app, defaultLifetime, params) + case codersdk.OAuth2ProviderGrantTypeAuthorizationCode: + token, err = authorizationCodeGrant(ctx, db, app, defaultLifetime, params) + default: + // Grant types are validated by the parser, so getting through here means + // the developer added a type but forgot to add a case here. + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Unhandled grant type.", + Detail: fmt.Sprintf("Grant type %q is unhandled", params.grantType), + }) + return + } + + if errors.Is(err, errBadCode) || errors.Is(err, errBadSecret) { + httpapi.Write(r.Context(), rw, http.StatusUnauthorized, codersdk.Response{ + Message: err.Error(), + }) + return + } + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to exchange token", + Detail: err.Error(), + }) + return + } + + // Some client libraries allow this to be "application/x-www-form-urlencoded". We can implement that upon + // request. The same libraries should also accept JSON. If implemented, choose based on "Accept" header. + httpapi.Write(ctx, rw, http.StatusOK, token) + } +} + +func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, defaultLifetime time.Duration, params tokenParams) (oauth2.Token, error) { + // Validate the client secret. + secret, err := parseSecret(params.clientSecret) + if err != nil { + return oauth2.Token{}, errBadSecret + } + //nolint:gocritic // Users cannot read secrets so we must use the system. + dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.prefix)) + if errors.Is(err, sql.ErrNoRows) { + return oauth2.Token{}, errBadSecret + } + if err != nil { + return oauth2.Token{}, err + } + equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.secret) + if err != nil { + return oauth2.Token{}, xerrors.Errorf("unable to compare secret: %w", err) + } + if !equal { + return oauth2.Token{}, errBadSecret + } + + // Validate the authorization code. + code, err := parseSecret(params.code) + if err != nil { + return oauth2.Token{}, errBadCode + } + //nolint:gocritic // There is no user yet so we must use the system. + dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.prefix)) + if errors.Is(err, sql.ErrNoRows) { + return oauth2.Token{}, errBadCode + } + if err != nil { + return oauth2.Token{}, err + } + equal, err = userpassword.Compare(string(dbCode.HashedSecret), code.secret) + if err != nil { + return oauth2.Token{}, xerrors.Errorf("unable to compare code: %w", err) + } + if !equal { + return oauth2.Token{}, errBadCode + } + + // Ensure the code has not expired. + if dbCode.ExpiresAt.Before(dbtime.Now()) { + return oauth2.Token{}, errBadCode + } + + // Generate a refresh token. + refreshToken, err := GenerateSecret() + if err != nil { + return oauth2.Token{}, err + } + + // Generate the API key we will swap for the code. + // TODO: We are ignoring scopes for now. + tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbCode.UserID, app.ID) + key, sessionToken, err := apikey.Generate(apikey.CreateParams{ + UserID: dbCode.UserID, + LoginType: database.LoginTypeOAuth2ProviderApp, + // TODO: This is just the lifetime for api keys, maybe have its own config + // settings. #11693 + DefaultLifetime: defaultLifetime, + // For now, we allow only one token per app and user at a time. + TokenName: tokenName, + }) + if err != nil { + return oauth2.Token{}, err + } + + // Grab the user roles so we can perform the exchange as the user. + //nolint:gocritic // In the token exchange, there is no user actor. + roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), dbCode.UserID) + if err != nil { + return oauth2.Token{}, err + } + userSubj := rbac.Subject{ + ID: dbCode.UserID.String(), + Roles: rbac.RoleNames(roles.Roles), + Groups: roles.Groups, + Scope: rbac.ScopeAll, + } + + // Do the actual token exchange in the database. + err = db.InTx(func(tx database.Store) error { + ctx := dbauthz.As(ctx, userSubj) + err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID) + if err != nil { + return xerrors.Errorf("delete oauth2 app code: %w", err) + } + + // Delete the previous key, if any. + prevKey, err := tx.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{ + UserID: dbCode.UserID, + TokenName: tokenName, + }) + if err == nil { + err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete api key by name: %w", err) + } + + newKey, err := tx.InsertAPIKey(ctx, key) + if err != nil { + return xerrors.Errorf("insert oauth2 access token: %w", err) + } + + _, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + ExpiresAt: key.ExpiresAt, + HashPrefix: []byte(refreshToken.Prefix), + RefreshHash: []byte(refreshToken.Hashed), + AppSecretID: dbSecret.ID, + APIKeyID: newKey.ID, + }) + if err != nil { + return xerrors.Errorf("insert oauth2 refresh token: %w", err) + } + return nil + }, nil) + if err != nil { + return oauth2.Token{}, err + } + + return oauth2.Token{ + AccessToken: sessionToken, + TokenType: "Bearer", + RefreshToken: refreshToken.Formatted, + Expiry: key.ExpiresAt, + }, nil +} + +func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, defaultLifetime time.Duration, params tokenParams) (oauth2.Token, error) { + // Validate the token. + token, err := parseSecret(params.refreshToken) + if err != nil { + return oauth2.Token{}, errBadToken + } + //nolint:gocritic // There is no user yet so we must use the system. + dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.prefix)) + if errors.Is(err, sql.ErrNoRows) { + return oauth2.Token{}, errBadToken + } + if err != nil { + return oauth2.Token{}, err + } + equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.secret) + if err != nil { + return oauth2.Token{}, xerrors.Errorf("unable to compare token: %w", err) + } + if !equal { + return oauth2.Token{}, errBadToken + } + + // Ensure the token has not expired. + if dbToken.ExpiresAt.Before(dbtime.Now()) { + return oauth2.Token{}, errBadToken + } + + // Grab the user roles so we can perform the refresh as the user. + //nolint:gocritic // There is no user yet so we must use the system. + prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID) + if err != nil { + return oauth2.Token{}, err + } + //nolint:gocritic // There is no user yet so we must use the system. + roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), prevKey.UserID) + if err != nil { + return oauth2.Token{}, err + } + userSubj := rbac.Subject{ + ID: prevKey.UserID.String(), + Roles: rbac.RoleNames(roles.Roles), + Groups: roles.Groups, + Scope: rbac.ScopeAll, + } + + // Generate a new refresh token. + refreshToken, err := GenerateSecret() + if err != nil { + return oauth2.Token{}, err + } + + // Generate the new API key. + // TODO: We are ignoring scopes for now. + tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID) + key, sessionToken, err := apikey.Generate(apikey.CreateParams{ + UserID: prevKey.UserID, + LoginType: database.LoginTypeOAuth2ProviderApp, + // TODO: This is just the lifetime for api keys, maybe have its own config + // settings. #11693 + DefaultLifetime: defaultLifetime, + // For now, we allow only one token per app and user at a time. + TokenName: tokenName, + }) + if err != nil { + return oauth2.Token{}, err + } + + // Replace the token. + err = db.InTx(func(tx database.Store) error { + ctx := dbauthz.As(ctx, userSubj) + err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token. + if err != nil { + return xerrors.Errorf("delete oauth2 app token: %w", err) + } + + newKey, err := tx.InsertAPIKey(ctx, key) + if err != nil { + return xerrors.Errorf("insert oauth2 access token: %w", err) + } + + _, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + ExpiresAt: key.ExpiresAt, + HashPrefix: []byte(refreshToken.Prefix), + RefreshHash: []byte(refreshToken.Hashed), + AppSecretID: dbToken.AppSecretID, + APIKeyID: newKey.ID, + }) + if err != nil { + return xerrors.Errorf("insert oauth2 refresh token: %w", err) + } + return nil + }, nil) + if err != nil { + return oauth2.Token{}, err + } + + return oauth2.Token{ + AccessToken: sessionToken, + TokenType: "Bearer", + RefreshToken: refreshToken.Formatted, + Expiry: key.ExpiresAt, + }, nil +} diff --git a/enterprise/coderd/jfrog.go b/enterprise/coderd/jfrog.go index 7195aee908dc9..9262c673eb1b8 100644 --- a/enterprise/coderd/jfrog.go +++ b/enterprise/coderd/jfrog.go @@ -67,8 +67,8 @@ func (api *API) jFrogXrayScan(rw http.ResponseWriter, r *http.Request) { ctx = r.Context() vals = r.URL.Query() p = httpapi.NewQueryParamParser() - wsID = p.Required("workspace_id").UUID(vals, uuid.UUID{}, "workspace_id") - agentID = p.Required("agent_id").UUID(vals, uuid.UUID{}, "agent_id") + wsID = p.RequiredNotEmpty("workspace_id").UUID(vals, uuid.UUID{}, "workspace_id") + agentID = p.RequiredNotEmpty("agent_id").UUID(vals, uuid.UUID{}, "agent_id") ) if len(p.Errors) > 0 { diff --git a/enterprise/coderd/oauth2.go b/enterprise/coderd/oauth2.go index 675eb17dd0b13..0f016d6533edb 100644 --- a/enterprise/coderd/oauth2.go +++ b/enterprise/coderd/oauth2.go @@ -1,7 +1,7 @@ package coderd import ( - "crypto/sha256" + "fmt" "net/http" "github.com/google/uuid" @@ -13,7 +13,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/cryptorand" + "github.com/coder/coder/v2/enterprise/coderd/identityprovider" ) func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler { @@ -45,16 +45,43 @@ func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler { // @Security CoderSessionToken // @Produce json // @Tags Enterprise +// @Param user_id query string false "Filter by applications authorized for a user" // @Success 200 {array} codersdk.OAuth2ProviderApp // @Router /oauth2-provider/apps [get] func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - dbApps, err := api.Database.GetOAuth2ProviderApps(ctx) + + rawUserID := r.URL.Query().Get("user_id") + if rawUserID == "" { + dbApps, err := api.Database.GetOAuth2ProviderApps(ctx) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(api.AccessURL, dbApps)) + return + } + + userID, err := uuid.Parse(rawUserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid user UUID", + Detail: fmt.Sprintf("queried user_id=%q", userID), + }) + return + } + + userApps, err := api.Database.GetOAuth2ProviderAppsByUserID(ctx, userID) if err != nil { httpapi.InternalServerError(rw, err) return } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(api.AccessURL, dbApps)) + + var sdkApps []codersdk.OAuth2ProviderApp + for _, app := range userApps { + sdkApps = append(sdkApps, db2sdk.OAuth2ProviderApp(api.AccessURL, app.OAuth2ProviderApp)) + } + httpapi.Write(ctx, rw, http.StatusOK, sdkApps) } // @Summary Get OAuth2 application. @@ -130,7 +157,7 @@ func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) { }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error creating OAuth2 application.", + Message: "Internal error updating OAuth2 application.", Detail: err.Error(), }) return @@ -200,27 +227,23 @@ func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() app := httpmw.OAuth2ProviderApp(r) - // 40 characters matches the length of GitHub's client secrets. - rawSecret, err := cryptorand.String(40) + secret, err := identityprovider.GenerateSecret() if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to generate OAuth2 client secret.", + Detail: err.Error(), }) return } - // TODO: Currently unused. - prefix, _ := cryptorand.String(40) - - hashed := sha256.Sum256([]byte(rawSecret)) - secret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{ + dbSecret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{ ID: uuid.New(), CreatedAt: dbtime.Now(), - SecretPrefix: []byte(prefix), - HashedSecret: hashed[:], + SecretPrefix: []byte(secret.Prefix), + HashedSecret: []byte(secret.Hashed), // DisplaySecret is the last six characters of the original unhashed secret. // This is done so they can be differentiated and it matches how GitHub // displays their client secrets. - DisplaySecret: rawSecret[len(rawSecret)-6:], + DisplaySecret: secret.Formatted[len(secret.Formatted)-6:], AppID: app.ID, }) if err != nil { @@ -231,8 +254,8 @@ func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Requ return } httpapi.Write(ctx, rw, http.StatusOK, codersdk.OAuth2ProviderAppSecretFull{ - ID: secret.ID, - ClientSecretFull: rawSecret, + ID: dbSecret.ID, + ClientSecretFull: secret.Formatted, }) } @@ -257,3 +280,44 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re } httpapi.Write(ctx, rw, http.StatusNoContent, nil) } + +// @Summary OAuth2 authorization request. +// @ID oauth2-authorization-request +// @Security CoderSessionToken +// @Tags Enterprise +// @Param client_id query string true "Client ID" +// @Param state query string true "A random unguessable string" +// @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type" +// @Param redirect_uri query string false "Redirect here after authorization" +// @Param scope query string false "Token scopes (currently ignored)" +// @Success 302 +// @Router /login/oauth2/authorize [post] +func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc { + return identityprovider.Authorize(api.Database, api.AccessURL) +} + +// @Summary OAuth2 token exchange. +// @ID oauth2-token-exchange +// @Produce json +// @Tags Enterprise +// @Param client_id formData string false "Client ID, required if grant_type=authorization_code" +// @Param client_secret formData string false "Client secret, required if grant_type=authorization_code" +// @Param code formData string false "Authorization code, required if grant_type=authorization_code" +// @Param refresh_token formData string false "Refresh token, required if grant_type=refresh_token" +// @Param grant_type formData codersdk.OAuth2ProviderGrantType true "Grant type" +// @Success 200 {object} oauth2.Token +// @Router /login/oauth2/tokens [post] +func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc { + return identityprovider.Tokens(api.Database, api.DeploymentValues.SessionDuration.Value()) +} + +// @Summary Delete OAuth2 application tokens. +// @ID delete-oauth2-application-tokens +// @Security CoderSessionToken +// @Tags Enterprise +// @Param client_id query string true "Client ID" +// @Success 204 +// @Router /login/oauth2/tokens [delete] +func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc { + return identityprovider.RevokeApp(api.Database) +} diff --git a/enterprise/coderd/oauth2_test.go b/enterprise/coderd/oauth2_test.go index 8a2e4df7bc65f..94d221882a4ec 100644 --- a/enterprise/coderd/oauth2_test.go +++ b/enterprise/coderd/oauth2_test.go @@ -1,19 +1,34 @@ package coderd_test import ( - "strconv" + "context" + "fmt" + "net/http" + "net/url" + "path" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + "github.com/coder/coder/v2/coderd/apikey" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/oidctest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/userpassword" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/identityprovider" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/coder/v2/testutil" ) -func TestOAuthApps(t *testing.T) { +func TestOAuth2ProviderApps(t *testing.T) { t.Parallel() t.Run("Validation", func(t *testing.T) { @@ -25,7 +40,7 @@ func TestOAuthApps(t *testing.T) { }, }}) - ctx := testutil.Context(t, testutil.WaitLong) + topCtx := testutil.Context(t, testutil.WaitLong) tests := []struct { name string @@ -128,7 +143,7 @@ func TestOAuthApps(t *testing.T) { CallbackURL: "http://coder.com", } //nolint:gocritic // OAauth2 app management requires owner permission. - _, err := client.PostOAuth2ProviderApp(ctx, req) + _, err := client.PostOAuth2ProviderApp(topCtx, req) require.NoError(t, err) // Generate an application for testing PUTs. @@ -137,13 +152,14 @@ func TestOAuthApps(t *testing.T) { CallbackURL: "http://coder.com", } //nolint:gocritic // OAauth2 app management requires owner permission. - existingApp, err := client.PostOAuth2ProviderApp(ctx, req) + existingApp, err := client.PostOAuth2ProviderApp(topCtx, req) require.NoError(t, err) for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) //nolint:gocritic // OAauth2 app management requires owner permission. _, err := client.PostOAuth2ProviderApp(ctx, test.req) @@ -162,71 +178,62 @@ func TestOAuthApps(t *testing.T) { t.Run("DeleteNonExisting", func(t *testing.T) { t.Parallel() - client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureOAuth2Provider: 1, }, }}) + another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) - //nolint:gocritic // OAauth2 app management requires owner permission. - _, err := client.OAuth2ProviderApp(ctx, uuid.New()) + _, err := another.OAuth2ProviderApp(ctx, uuid.New()) require.Error(t, err) }) t.Run("OK", func(t *testing.T) { t.Parallel() - client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ codersdk.FeatureOAuth2Provider: 1, }, }}) + another, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) ctx := testutil.Context(t, testutil.WaitLong) // No apps yet. - //nolint:gocritic // OAauth2 app management requires owner permission. - apps, err := client.OAuth2ProviderApps(ctx) + apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 0) // Should be able to add apps. - expected := []codersdk.OAuth2ProviderApp{} - for i := 0; i < 5; i++ { - postReq := codersdk.PostOAuth2ProviderAppRequest{ - Name: "foo-" + strconv.Itoa(i), - CallbackURL: "http://" + strconv.Itoa(i) + ".localhost:3000", - } - //nolint:gocritic // OAauth2 app management requires owner permission. - app, err := client.PostOAuth2ProviderApp(ctx, postReq) - require.NoError(t, err) - require.Equal(t, postReq.Name, app.Name) - require.Equal(t, postReq.CallbackURL, app.CallbackURL) - expected = append(expected, app) + expected := generateApps(ctx, t, client, "get-apps") + expectedOrder := []codersdk.OAuth2ProviderApp{ + expected.Default, expected.NoPort, expected.Subdomain, + expected.Extra[0], expected.Extra[1], } // Should get all the apps now. - //nolint:gocritic // OAauth2 app management requires owner permission. - apps, err = client.OAuth2ProviderApps(ctx) + apps, err = another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, apps, 5) - require.Equal(t, expected, apps) + require.Equal(t, expectedOrder, apps) // Should be able to keep the same name when updating. req := codersdk.PutOAuth2ProviderAppRequest{ - Name: expected[0].Name, + Name: expected.Default.Name, CallbackURL: "http://coder.com", Icon: "test", } //nolint:gocritic // OAauth2 app management requires owner permission. - newApp, err := client.PutOAuth2ProviderApp(ctx, expected[0].ID, req) + newApp, err := client.PutOAuth2ProviderApp(ctx, expected.Default.ID, req) require.NoError(t, err) require.Equal(t, req.Name, newApp.Name) require.Equal(t, req.CallbackURL, newApp.CallbackURL) require.Equal(t, req.Icon, newApp.Icon) - require.Equal(t, expected[0].ID, newApp.ID) + require.Equal(t, expected.Default.ID, newApp.ID) // Should be able to update name. req = codersdk.PutOAuth2ProviderAppRequest{ @@ -235,34 +242,50 @@ func TestOAuthApps(t *testing.T) { Icon: "test", } //nolint:gocritic // OAauth2 app management requires owner permission. - newApp, err = client.PutOAuth2ProviderApp(ctx, expected[0].ID, req) + newApp, err = client.PutOAuth2ProviderApp(ctx, expected.Default.ID, req) require.NoError(t, err) require.Equal(t, req.Name, newApp.Name) require.Equal(t, req.CallbackURL, newApp.CallbackURL) require.Equal(t, req.Icon, newApp.Icon) - require.Equal(t, expected[0].ID, newApp.ID) + require.Equal(t, expected.Default.ID, newApp.ID) // Should be able to get a single app. - //nolint:gocritic // OAauth2 app management requires owner permission. - got, err := client.OAuth2ProviderApp(ctx, expected[0].ID) + got, err := another.OAuth2ProviderApp(ctx, expected.Default.ID) require.NoError(t, err) require.Equal(t, newApp, got) // Should be able to delete an app. //nolint:gocritic // OAauth2 app management requires owner permission. - err = client.DeleteOAuth2ProviderApp(ctx, expected[0].ID) + err = client.DeleteOAuth2ProviderApp(ctx, expected.Default.ID) require.NoError(t, err) // Should show the new count. - //nolint:gocritic // OAauth2 app management requires owner permission. - newApps, err := client.OAuth2ProviderApps(ctx) + newApps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{}) require.NoError(t, err) require.Len(t, newApps, 4) - require.Equal(t, expected[1:], newApps) + + require.Equal(t, expectedOrder[1:], newApps) + }) + + t.Run("ByUser", func(t *testing.T) { + t.Parallel() + client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + another, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + _ = generateApps(ctx, t, client, "by-user") + apps, err := another.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{ + UserID: user.ID, + }) + require.NoError(t, err) + require.Len(t, apps, 0) }) } -func TestOAuthAppSecrets(t *testing.T) { +func TestOAuth2ProviderAppSecrets(t *testing.T) { t.Parallel() client, _ := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ @@ -274,19 +297,7 @@ func TestOAuthAppSecrets(t *testing.T) { topCtx := testutil.Context(t, testutil.WaitLong) // Make some apps. - //nolint:gocritic // OAauth2 app management requires owner permission. - app1, err := client.PostOAuth2ProviderApp(topCtx, codersdk.PostOAuth2ProviderAppRequest{ - Name: "razzle-dazzle", - CallbackURL: "http://localhost", - }) - require.NoError(t, err) - - //nolint:gocritic // OAauth2 app management requires owner permission. - app2, err := client.PostOAuth2ProviderApp(topCtx, codersdk.PostOAuth2ProviderAppRequest{ - Name: "razzle-dazzle-the-sequel", - CallbackURL: "http://localhost", - }) - require.NoError(t, err) + apps := generateApps(topCtx, t, client, "app-secrets") t.Run("DeleteNonExisting", func(t *testing.T) { t.Parallel() @@ -294,7 +305,7 @@ func TestOAuthAppSecrets(t *testing.T) { // Should not be able to create secrets for a non-existent app. //nolint:gocritic // OAauth2 app management requires owner permission. - _, err = client.OAuth2ProviderAppSecrets(ctx, uuid.New()) + _, err := client.OAuth2ProviderAppSecrets(ctx, uuid.New()) require.Error(t, err) // Should not be able to delete non-existing secrets when there is no app. @@ -304,16 +315,16 @@ func TestOAuthAppSecrets(t *testing.T) { // Should not be able to delete non-existing secrets when the app exists. //nolint:gocritic // OAauth2 app management requires owner permission. - err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, uuid.New()) + err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, uuid.New()) require.Error(t, err) // Should not be able to delete an existing secret with the wrong app ID. //nolint:gocritic // OAauth2 app management requires owner permission. - secret, err := client.PostOAuth2ProviderAppSecret(ctx, app2.ID) + secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID) require.NoError(t, err) //nolint:gocritic // OAauth2 app management requires owner permission. - err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secret.ID) + err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secret.ID) require.Error(t, err) }) @@ -323,26 +334,26 @@ func TestOAuthAppSecrets(t *testing.T) { // No secrets yet. //nolint:gocritic // OAauth2 app management requires owner permission. - secrets, err := client.OAuth2ProviderAppSecrets(ctx, app1.ID) + secrets, err := client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID) require.NoError(t, err) require.Len(t, secrets, 0) // Should be able to create secrets. for i := 0; i < 5; i++ { //nolint:gocritic // OAauth2 app management requires owner permission. - secret, err := client.PostOAuth2ProviderAppSecret(ctx, app1.ID) + secret, err := client.PostOAuth2ProviderAppSecret(ctx, apps.Default.ID) require.NoError(t, err) require.NotEmpty(t, secret.ClientSecretFull) require.True(t, len(secret.ClientSecretFull) > 6) //nolint:gocritic // OAauth2 app management requires owner permission. - _, err = client.PostOAuth2ProviderAppSecret(ctx, app2.ID) + _, err = client.PostOAuth2ProviderAppSecret(ctx, apps.NoPort.ID) require.NoError(t, err) } // Should get secrets now, but only for the one app. //nolint:gocritic // OAauth2 app management requires owner permission. - secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID) require.NoError(t, err) require.Len(t, secrets, 5) for _, secret := range secrets { @@ -351,19 +362,779 @@ func TestOAuthAppSecrets(t *testing.T) { // Should be able to delete a secret. //nolint:gocritic // OAauth2 app management requires owner permission. - err = client.DeleteOAuth2ProviderAppSecret(ctx, app1.ID, secrets[0].ID) + err = client.DeleteOAuth2ProviderAppSecret(ctx, apps.Default.ID, secrets[0].ID) require.NoError(t, err) - secrets, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + secrets, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID) require.NoError(t, err) require.Len(t, secrets, 4) // No secrets once the app is deleted. //nolint:gocritic // OAauth2 app management requires owner permission. - err = client.DeleteOAuth2ProviderApp(ctx, app1.ID) + err = client.DeleteOAuth2ProviderApp(ctx, apps.Default.ID) require.NoError(t, err) //nolint:gocritic // OAauth2 app management requires owner permission. - _, err = client.OAuth2ProviderAppSecrets(ctx, app1.ID) + _, err = client.OAuth2ProviderAppSecrets(ctx, apps.Default.ID) require.Error(t, err) }) } + +func TestOAuth2ProviderTokenExchange(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }, + }) + topCtx := testutil.Context(t, testutil.WaitLong) + apps := generateApps(topCtx, t, ownerClient, "token-exchange") + + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := ownerClient.PostOAuth2ProviderAppSecret(topCtx, apps.Default.ID) + require.NoError(t, err) + + // The typical oauth2 flow from this point is: + // Create an oauth2.Config using the id, secret, endpoints, and redirect: + // cfg := oauth2.Config{ ... } + // Display url for the user to click: + // userClickURL := cfg.AuthCodeURL("random_state") + // userClickURL looks like: https://idp url/authorize? + // client_id=... + // response_type=code + // redirect_uri=.. (back to backstage url) .. + // scope=... + // state=... + // *1* User clicks "Allow" on provided page above + // The redirect_uri is followed which sends back to backstage with the code and state + // Now backstage has the info to do a cfg.Exchange() in the back to get an access token. + // + // ---NOTE---: If the user has already approved this oauth app, then *1* is optional. + // Coder can just immediately redirect back to backstage without user intervention. + tests := []struct { + name string + app codersdk.OAuth2ProviderApp + // The flow is setup(ctx, client, user) -> preAuth(cfg) -> cfg.AuthCodeURL() -> preToken(cfg) -> cfg.Exchange() + setup func(context.Context, *codersdk.Client, codersdk.User) error + preAuth func(valid *oauth2.Config) + authError string + preToken func(valid *oauth2.Config) + tokenError string + + // If null, assume the code should be valid. + defaultCode *string + // custom allows some more advanced manipulation of the oauth2 exchange. + exchangeMutate []oauth2.AuthCodeOption + }{ + { + name: "AuthInParams", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + valid.Endpoint.AuthStyle = oauth2.AuthStyleInParams + }, + }, + { + name: "AuthInvalidAppID", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + valid.ClientID = uuid.NewString() + }, + authError: "Resource not found", + }, + { + name: "TokenInvalidAppID", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientID = uuid.NewString() + }, + tokenError: "Resource not found", + }, + { + name: "InvalidPort", + app: apps.NoPort, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Host = newURL.Hostname() + ":8081" + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "WrongAppHost", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + valid.RedirectURL = apps.NoPort.CallbackURL + }, + authError: "Invalid query params", + }, + { + name: "InvalidHostPrefix", + app: apps.NoPort, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Host = "prefix" + newURL.Hostname() + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "InvalidHost", + app: apps.NoPort, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Host = "invalid" + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "InvalidHostAndPort", + app: apps.NoPort, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Host = "invalid:8080" + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "InvalidPath", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Path = path.Join("/prepend", newURL.Path) + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "MissingPath", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Path = "/" + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + // TODO: This is valid for now, but should it be? + name: "DifferentProtocol", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Scheme = "https" + valid.RedirectURL = newURL.String() + }, + }, + { + name: "NestedPath", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Path = path.Join(newURL.Path, "nested") + valid.RedirectURL = newURL.String() + }, + }, + { + // Some oauth implementations allow this, but our users can host + // at subdomains. So we should not. + name: "Subdomain", + app: apps.Default, + preAuth: func(valid *oauth2.Config) { + newURL := must(url.Parse(valid.RedirectURL)) + newURL.Host = "sub." + newURL.Host + valid.RedirectURL = newURL.String() + }, + authError: "Invalid query params", + }, + { + name: "NoSecretScheme", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "1234_4321" + }, + tokenError: "Invalid client secret", + }, + { + name: "InvalidSecretScheme", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "notcoder_1234_4321" + }, + tokenError: "Invalid client secret", + }, + { + name: "MissingSecretSecret", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "coder_1234" + }, + tokenError: "Invalid client secret", + }, + { + name: "MissingSecretPrefix", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "coder__1234" + }, + tokenError: "Invalid client secret", + }, + { + name: "InvalidSecretPrefix", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "coder_1234_4321" + }, + tokenError: "Invalid client secret", + }, + { + name: "MissingSecret", + app: apps.Default, + preToken: func(valid *oauth2.Config) { + valid.ClientSecret = "" + }, + tokenError: "Invalid query params", + }, + { + name: "NoCodeScheme", + app: apps.Default, + defaultCode: ptr.Ref("1234_4321"), + tokenError: "Invalid code", + }, + { + name: "InvalidCodeScheme", + app: apps.Default, + defaultCode: ptr.Ref("notcoder_1234_4321"), + tokenError: "Invalid code", + }, + { + name: "MissingCodeSecret", + app: apps.Default, + defaultCode: ptr.Ref("coder_1234"), + tokenError: "Invalid code", + }, + { + name: "MissingCodePrefix", + app: apps.Default, + defaultCode: ptr.Ref("coder__1234"), + tokenError: "Invalid code", + }, + { + name: "InvalidCodePrefix", + app: apps.Default, + defaultCode: ptr.Ref("coder_1234_4321"), + tokenError: "Invalid code", + }, + { + name: "MissingCode", + app: apps.Default, + defaultCode: ptr.Ref(""), + tokenError: "Invalid query params", + }, + { + name: "InvalidGrantType", + app: apps.Default, + tokenError: "Invalid query params", + exchangeMutate: []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", "foobar"), + }, + }, + { + name: "EmptyGrantType", + app: apps.Default, + tokenError: "Invalid query params", + exchangeMutate: []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("grant_type", ""), + }, + }, + { + name: "ExpiredCode", + app: apps.Default, + defaultCode: ptr.Ref("coder_prefix_code"), + tokenError: "Invalid code", + setup: func(ctx context.Context, client *codersdk.Client, user codersdk.User) error { + // Insert an expired code. + hashedCode, err := userpassword.Hash("prefix_code") + if err != nil { + return err + } + _, err = db.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now().Add(-time.Minute * 11), + ExpiresAt: dbtime.Now().Add(-time.Minute), + SecretPrefix: []byte("prefix"), + HashedSecret: []byte(hashedCode), + AppID: apps.Default.ID, + UserID: user.ID, + }) + return err + }, + }, + { + name: "OK", + app: apps.Default, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + // Each test gets its own user, since we allow only one code per user and + // app at a time and running tests in parallel could clobber each other. + userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + if test.setup != nil { + err := test.setup(ctx, userClient, user) + require.NoError(t, err) + } + + // Each test gets its own oauth2.Config so they can run in parallel. + // In practice, you would only use 1 as a singleton. + valid := &oauth2.Config{ + ClientID: test.app.ID.String(), + ClientSecret: secret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: test.app.Endpoints.Authorization, + DeviceAuthURL: test.app.Endpoints.DeviceAuth, + TokenURL: test.app.Endpoints.Token, + // TODO: @emyrk we should support both types. + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: test.app.CallbackURL, + Scopes: []string{}, + } + + if test.preAuth != nil { + test.preAuth(valid) + } + + var code string + if test.defaultCode != nil { + code = *test.defaultCode + } else { + var err error + code, err = authorizationFlow(ctx, userClient, valid) + if test.authError != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.authError) + // If this errors the token exchange will fail. So end here. + return + } + require.NoError(t, err) + } + + // Mutate the valid config for the exchange. + if test.preToken != nil { + test.preToken(valid) + } + + // Do the actual exchange. + token, err := valid.Exchange(ctx, code, test.exchangeMutate...) + if test.tokenError != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.tokenError) + } else { + require.NoError(t, err) + require.NotEmpty(t, token.AccessToken) + require.True(t, time.Now().After(token.Expiry)) + + // Check that the token works. + newClient := codersdk.New(userClient.URL) + newClient.SetSessionToken(token.AccessToken) + + gotUser, err := newClient.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, user.ID, gotUser.ID) + } + }) + } +} + +func TestOAuth2ProviderTokenRefresh(t *testing.T) { + t.Parallel() + topCtx := testutil.Context(t, testutil.WaitLong) + + db, pubsub := dbtestutil.NewDB(t) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }, + }) + apps := generateApps(topCtx, t, ownerClient, "token-refresh") + + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := ownerClient.PostOAuth2ProviderAppSecret(topCtx, apps.Default.ID) + require.NoError(t, err) + + // One path not tested here is when the token is empty, because Go's OAuth2 + // client library will not even try to make the request. + tests := []struct { + name string + app codersdk.OAuth2ProviderApp + // If null, assume the token should be valid. + defaultToken *string + error string + expires time.Time + }{ + { + name: "NoTokenScheme", + app: apps.Default, + defaultToken: ptr.Ref("1234_4321"), + error: "Invalid token", + }, + { + name: "InvalidTokenScheme", + app: apps.Default, + defaultToken: ptr.Ref("notcoder_1234_4321"), + error: "Invalid token", + }, + { + name: "MissingTokenSecret", + app: apps.Default, + defaultToken: ptr.Ref("coder_1234"), + error: "Invalid token", + }, + { + name: "MissingTokenPrefix", + app: apps.Default, + defaultToken: ptr.Ref("coder__1234"), + error: "Invalid token", + }, + { + name: "InvalidTokenPrefix", + app: apps.Default, + defaultToken: ptr.Ref("coder_1234_4321"), + error: "Invalid token", + }, + { + name: "Expired", + app: apps.Default, + expires: time.Now().Add(time.Minute * -1), + error: "Invalid token", + }, + { + name: "OK", + app: apps.Default, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + userClient, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Insert the token and its key. + key, sessionToken, err := apikey.Generate(apikey.CreateParams{ + UserID: user.ID, + LoginType: database.LoginTypeOAuth2ProviderApp, + ExpiresAt: time.Now().Add(time.Hour * 10), + }) + require.NoError(t, err) + + newKey, err := db.InsertAPIKey(ctx, key) + require.NoError(t, err) + + token, err := identityprovider.GenerateSecret() + require.NoError(t, err) + + expires := test.expires + if expires.IsZero() { + expires = time.Now().Add(time.Hour * 10) + } + + _, err = db.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + ExpiresAt: expires, + HashPrefix: []byte(token.Prefix), + RefreshHash: []byte(token.Hashed), + AppSecretID: secret.ID, + APIKeyID: newKey.ID, + }) + require.NoError(t, err) + + // Check that the key works. + newClient := codersdk.New(userClient.URL) + newClient.SetSessionToken(sessionToken) + gotUser, err := newClient.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, user.ID, gotUser.ID) + + cfg := &oauth2.Config{ + ClientID: test.app.ID.String(), + ClientSecret: secret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: test.app.Endpoints.Authorization, + DeviceAuthURL: test.app.Endpoints.DeviceAuth, + TokenURL: test.app.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: test.app.CallbackURL, + Scopes: []string{}, + } + + // Test whether it can be refreshed. + refreshToken := token.Formatted + if test.defaultToken != nil { + refreshToken = *test.defaultToken + } + refreshed, err := cfg.TokenSource(ctx, &oauth2.Token{ + AccessToken: sessionToken, + RefreshToken: refreshToken, + Expiry: time.Now().Add(time.Minute * -1), + }).Token() + + if test.error != "" { + require.Error(t, err) + require.ErrorContains(t, err, test.error) + } else { + require.NoError(t, err) + require.NotEmpty(t, refreshed.AccessToken) + + // Old token is now invalid. + _, err = newClient.User(ctx, codersdk.Me) + require.Error(t, err) + require.ErrorContains(t, err, "401") + + // Refresh token is valid. + newClient := codersdk.New(userClient.URL) + newClient.SetSessionToken(refreshed.AccessToken) + + gotUser, err := newClient.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, user.ID, gotUser.ID) + } + }) + } +} + +type exchangeSetup struct { + cfg *oauth2.Config + app codersdk.OAuth2ProviderApp + secret codersdk.OAuth2ProviderAppSecretFull + code string +} + +func TestOAuth2ProviderRevoke(t *testing.T) { + t.Parallel() + + client, owner := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureOAuth2Provider: 1, + }, + }}) + + tests := []struct { + name string + // fn performs some action that removes the user's code and token. + fn func(context.Context, *codersdk.Client, exchangeSetup) + // replacesToken specifies whether the action replaces the token or only + // deletes it. + replacesToken bool + }{ + { + name: "DeleteApp", + fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) { + //nolint:gocritic // OAauth2 app management requires owner permission. + err := client.DeleteOAuth2ProviderApp(ctx, s.app.ID) + require.NoError(t, err) + }, + }, + { + name: "DeleteSecret", + fn: func(ctx context.Context, _ *codersdk.Client, s exchangeSetup) { + //nolint:gocritic // OAauth2 app management requires owner permission. + err := client.DeleteOAuth2ProviderAppSecret(ctx, s.app.ID, s.secret.ID) + require.NoError(t, err) + }, + }, + { + name: "DeleteToken", + fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) { + err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID) + require.NoError(t, err) + }, + }, + { + name: "OverrideCodeAndToken", + fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) { + // Generating a new code should wipe out the old code. + code, err := authorizationFlow(ctx, client, s.cfg) + require.NoError(t, err) + + // Generating a new token should wipe out the old token. + _, err = s.cfg.Exchange(ctx, code) + require.NoError(t, err) + }, + replacesToken: true, + }, + } + + setup := func(ctx context.Context, testClient *codersdk.Client, name string) exchangeSetup { + // We need a new app each time because we only allow one code and token per + // app and user at the moment and because the test might delete the app. + //nolint:gocritic // OAauth2 app management requires owner permission. + app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: name, + CallbackURL: "http://localhost", + }) + require.NoError(t, err) + + // We need a new secret every time because the test might delete the secret. + //nolint:gocritic // OAauth2 app management requires owner permission. + secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID) + require.NoError(t, err) + + cfg := &oauth2.Config{ + ClientID: app.ID.String(), + ClientSecret: secret.ClientSecretFull, + Endpoint: oauth2.Endpoint{ + AuthURL: app.Endpoints.Authorization, + DeviceAuthURL: app.Endpoints.DeviceAuth, + TokenURL: app.Endpoints.Token, + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: app.CallbackURL, + Scopes: []string{}, + } + + // Go through the auth flow to get a code. + code, err := authorizationFlow(ctx, testClient, cfg) + require.NoError(t, err) + + return exchangeSetup{ + cfg: cfg, + app: app, + secret: secret, + code: code, + } + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + testClient, testUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + + testEntities := setup(ctx, testClient, test.name+"-1") + + // Delete before the exchange completes (code should delete and attempting + // to finish the exchange should fail). + test.fn(ctx, testClient, testEntities) + + // Exchange should fail because the code should be gone. + _, err := testEntities.cfg.Exchange(ctx, testEntities.code) + require.Error(t, err) + + // Try again, this time letting the exchange complete first. + testEntities = setup(ctx, testClient, test.name+"-2") + token, err := testEntities.cfg.Exchange(ctx, testEntities.code) + require.NoError(t, err) + + // Validate the returned access token and that the app is listed. + newClient := codersdk.New(client.URL) + newClient.SetSessionToken(token.AccessToken) + + gotUser, err := newClient.User(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, testUser.ID, gotUser.ID) + + filter := codersdk.OAuth2ProviderAppFilter{UserID: testUser.ID} + apps, err := testClient.OAuth2ProviderApps(ctx, filter) + require.NoError(t, err) + require.Contains(t, apps, testEntities.app) + + // Should not show up for another user. + apps, err = client.OAuth2ProviderApps(ctx, codersdk.OAuth2ProviderAppFilter{UserID: owner.UserID}) + require.NoError(t, err) + require.Len(t, apps, 0) + + // Perform the deletion. + test.fn(ctx, testClient, testEntities) + + // App should no longer show up for the user unless it was replaced. + if !test.replacesToken { + apps, err = testClient.OAuth2ProviderApps(ctx, filter) + require.NoError(t, err) + require.NotContains(t, apps, testEntities.app, fmt.Sprintf("contains %q", testEntities.app.Name)) + } + + // The token should no longer be valid. + _, err = newClient.User(ctx, codersdk.Me) + require.Error(t, err) + require.ErrorContains(t, err, "401") + }) + } +} + +type provisionedApps struct { + Default codersdk.OAuth2ProviderApp + NoPort codersdk.OAuth2ProviderApp + Subdomain codersdk.OAuth2ProviderApp + // For sorting purposes these are included. You will likely never touch them. + Extra []codersdk.OAuth2ProviderApp +} + +func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, suffix string) provisionedApps { + create := func(name, callback string) codersdk.OAuth2ProviderApp { + name = fmt.Sprintf("%s-%s", name, suffix) + //nolint:gocritic // OAauth2 app management requires owner permission. + app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ + Name: name, + CallbackURL: callback, + Icon: "", + }) + require.NoError(t, err) + require.Equal(t, name, app.Name) + require.Equal(t, callback, app.CallbackURL) + return app + } + + return provisionedApps{ + Default: create("razzle-dazzle-a", "http://localhost1:8080/foo/bar"), + NoPort: create("razzle-dazzle-b", "http://localhost2"), + Subdomain: create("razzle-dazzle-z", "http://30.localhost:3000"), + Extra: []codersdk.OAuth2ProviderApp{ + create("second-to-last", "http://20.localhost:3000"), + create("woo-10", "http://10.localhost:3000"), + }, + } +} + +func authorizationFlow(ctx context.Context, client *codersdk.Client, cfg *oauth2.Config) (string, error) { + state := uuid.NewString() + return oidctest.OAuth2GetCode( + cfg.AuthCodeURL(state), + func(req *http.Request) (*http.Response, error) { + // TODO: Would be better if client had a .Do() method. + // TODO: Is this the best way to handle redirects? + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + return client.Request(ctx, req.Method, req.URL.String(), nil, func(req *http.Request) { + // Set the referer so the request bypasses the HTML page (normally you + // have to click "allow" first, and the way we detect that is using the + // referer header). + req.Header.Set("Referer", req.URL.String()) + }) + }, + ) +} diff --git a/site/site.go b/site/site.go index 0476565521ac9..591662c3fd417 100644 --- a/site/site.go +++ b/site/site.go @@ -51,6 +51,11 @@ var ( errorHTML string errorTemplate *htmltemplate.Template + + //go:embed static/oauth2allow.html + oauthHTML string + + oauthTemplate *htmltemplate.Template ) func init() { @@ -59,6 +64,11 @@ func init() { if err != nil { panic(err) } + + oauthTemplate, err = htmltemplate.New("error").Parse(oauthHTML) + if err != nil { + panic(err) + } } type Options struct { @@ -873,3 +883,31 @@ func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string { } return "Coder" } + +// RenderOAuthAllowData contains the variables that are found in +// site/static/oauth2allow.html. +type RenderOAuthAllowData struct { + AppIcon string + AppName string + CancelURI string + RedirectURI string + Username string +} + +// RenderOAuthAllowPage renders the static page for a user to "Allow" an create +// a new oauth2 link with an external site. This is when Coder is acting as the +// identity provider. +// +// This has to be done statically because Golang has to handle the full request. +// It cannot defer to the FE typescript easily. +func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) { + rw.Header().Set("Content-Type", "text/html; charset=utf-8") + + err := oauthTemplate.Execute(rw, data) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ + Message: "Failed to render oauth page: " + err.Error(), + }) + return + } +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9d63f492c29b6..820d1c1ae3514 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -700,6 +700,11 @@ export interface OAuth2ProviderApp { readonly endpoints: OAuth2AppEndpoints; } +// From codersdk/oauth2.go +export interface OAuth2ProviderAppFilter { + readonly user_id?: string; +} + // From codersdk/oauth2.go export interface OAuth2ProviderAppSecret { readonly id: string; @@ -1954,6 +1959,19 @@ export const LoginTypes: LoginType[] = [ "token", ]; +// From codersdk/oauth2.go +export type OAuth2ProviderGrantType = "authorization_code" | "refresh_token"; +export const OAuth2ProviderGrantTypes: OAuth2ProviderGrantType[] = [ + "authorization_code", + "refresh_token", +]; + +// From codersdk/oauth2.go +export type OAuth2ProviderResponseType = "code"; +export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ + "code", +]; + // From codersdk/provisionerdaemons.go export type ProvisionerJobStatus = | "canceled" diff --git a/site/static/oauth2allow.html b/site/static/oauth2allow.html new file mode 100644 index 0000000000000..a7a7aaffc3947 --- /dev/null +++ b/site/static/oauth2allow.html @@ -0,0 +1,168 @@ +{{/* This template is used by application handlers to render allowing oauth2 +links */}} + + + + + + + Application {{.AppName}} + + + +
+
+ {{- if .AppIcon }} + +
+
+ {{end}} + + + + + + + + + + + + + + + +
+

Authorize {{ .AppName }}

+

+ Allow {{ .AppName }} to have full access to your + {{ .Username }} account? +

+
+ Allow + Cancel +
+
+ +