mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add best effort attempt to revoke oauth access token in external auth provider (#19775)
Solves #15575 Adds OAuth access token revocation when unlinking external auth provider. Due to revocation not being consistently implemented by providers this is only best effort attempt. Unsuccessful revocation won't influence link removal.
This commit is contained in:
Generated
+25
-1
@@ -960,6 +960,9 @@ const docTemplate = `{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Git"
|
||||
],
|
||||
@@ -977,7 +980,10 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeleteExternalAuthByIDResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12770,6 +12776,18 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteExternalAuthByIDResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_revocation_error": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_revoked": {
|
||||
"description": "TokenRevoked set to true if token revocation was attempted and was successful",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13249,6 +13267,9 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
|
||||
}
|
||||
},
|
||||
"supports_revocation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"description": "User is the user that authenticated with the provider.",
|
||||
"allOf": [
|
||||
@@ -13322,6 +13343,9 @@ const docTemplate = `{
|
||||
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
|
||||
"type": "string"
|
||||
},
|
||||
"revoke_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
Generated
+23
-1
@@ -822,6 +822,7 @@
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Git"],
|
||||
"summary": "Delete external auth user link by ID",
|
||||
"operationId": "delete-external-auth-user-link-by-id",
|
||||
@@ -837,7 +838,10 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeleteExternalAuthByIDResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11413,6 +11417,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteExternalAuthByIDResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_revocation_error": {
|
||||
"type": "string"
|
||||
},
|
||||
"token_revoked": {
|
||||
"description": "TokenRevoked set to true if token revocation was attempted and was successful",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeleteWebpushSubscription": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -11885,6 +11901,9 @@
|
||||
"$ref": "#/definitions/codersdk.ExternalAuthAppInstallation"
|
||||
}
|
||||
},
|
||||
"supports_revocation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"description": "User is the user that authenticated with the provider.",
|
||||
"allOf": [
|
||||
@@ -11958,6 +11977,9 @@
|
||||
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
|
||||
"type": "string"
|
||||
},
|
||||
"revoke_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"scopes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -46,6 +46,8 @@ import (
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
type HookRevokeTokenFn func() (httpStatus int, err error)
|
||||
|
||||
type token struct {
|
||||
issued time.Time
|
||||
email string
|
||||
@@ -196,9 +198,11 @@ type FakeIDP struct {
|
||||
// hookValidRedirectURL can be used to reject a redirect url from the
|
||||
// IDP -> Application. Almost all IDPs have the concept of
|
||||
// "Authorized Redirect URLs". This can be used to emulate that.
|
||||
hookValidRedirectURL func(redirectURL string) error
|
||||
hookUserInfo func(email string) (jwt.MapClaims, error)
|
||||
hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims
|
||||
hookValidRedirectURL func(redirectURL string) error
|
||||
hookUserInfo func(email string) (jwt.MapClaims, error)
|
||||
hookRevokeToken HookRevokeTokenFn
|
||||
revokeTokenGitHubFormat bool // GitHub doesn't follow token revocation RFC spec
|
||||
hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims
|
||||
// defaultIDClaims is if a new client connects and we didn't preset
|
||||
// some claims.
|
||||
defaultIDClaims jwt.MapClaims
|
||||
@@ -327,6 +331,19 @@ func WithStaticUserInfo(info jwt.MapClaims) func(*FakeIDP) {
|
||||
}
|
||||
}
|
||||
|
||||
func WithRevokeTokenRFC(revokeFunc HookRevokeTokenFn) func(*FakeIDP) {
|
||||
return func(f *FakeIDP) {
|
||||
f.hookRevokeToken = revokeFunc
|
||||
}
|
||||
}
|
||||
|
||||
func WithRevokeTokenGitHub(revokeFunc HookRevokeTokenFn) func(*FakeIDP) {
|
||||
return func(f *FakeIDP) {
|
||||
f.hookRevokeToken = revokeFunc
|
||||
f.revokeTokenGitHubFormat = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithDefaultIDClaims(claims jwt.MapClaims) func(*FakeIDP) {
|
||||
return func(f *FakeIDP) {
|
||||
f.defaultIDClaims = claims
|
||||
@@ -358,6 +375,7 @@ type With429Arguments struct {
|
||||
AuthorizePath bool
|
||||
KeysPath bool
|
||||
UserInfoPath bool
|
||||
RevokePath bool
|
||||
DeviceAuth bool
|
||||
DeviceVerify bool
|
||||
}
|
||||
@@ -387,6 +405,10 @@ func With429(params With429Arguments) func(*FakeIDP) {
|
||||
http.Error(rw, "429, being manually blocked (userinfo)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if params.RevokePath && strings.Contains(r.URL.Path, revokeTokenPath) {
|
||||
http.Error(rw, "429, being manually blocked (revoke)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
if params.DeviceAuth && strings.Contains(r.URL.Path, deviceAuth) {
|
||||
http.Error(rw, "429, being manually blocked (device-auth)", http.StatusTooManyRequests)
|
||||
return
|
||||
@@ -408,8 +430,10 @@ const (
|
||||
authorizePath = "/oauth2/authorize"
|
||||
keysPath = "/oauth2/keys"
|
||||
userInfoPath = "/oauth2/userinfo"
|
||||
deviceAuth = "/login/device/code"
|
||||
deviceVerify = "/login/device"
|
||||
// nolint:gosec // It also thinks this is a secret lol
|
||||
revokeTokenPath = "/oauth2/revoke"
|
||||
deviceAuth = "/login/device/code"
|
||||
deviceVerify = "/login/device"
|
||||
)
|
||||
|
||||
func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
|
||||
@@ -486,6 +510,7 @@ func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
|
||||
TokenURL: u.ResolveReference(&url.URL{Path: tokenPath}).String(),
|
||||
JWKSURL: u.ResolveReference(&url.URL{Path: keysPath}).String(),
|
||||
UserInfoURL: u.ResolveReference(&url.URL{Path: userInfoPath}).String(),
|
||||
RevokeURL: u.ResolveReference(&url.URL{Path: revokeTokenPath}).String(),
|
||||
DeviceCodeURL: u.ResolveReference(&url.URL{Path: deviceAuth}).String(),
|
||||
Algorithms: []string{
|
||||
"RS256",
|
||||
@@ -756,6 +781,7 @@ type ProviderJSON struct {
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
UserInfoURL string `json:"userinfo_endpoint"`
|
||||
RevokeURL string `json:"revocation_endpoint"`
|
||||
DeviceCodeURL string `json:"device_authorization_endpoint"`
|
||||
Algorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
// This is custom
|
||||
@@ -1146,6 +1172,29 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
|
||||
_ = json.NewEncoder(rw).Encode(claims)
|
||||
}))
|
||||
|
||||
mux.Handle(revokeTokenPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if f.revokeTokenGitHubFormat {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || !(u == f.clientID && p == f.clientSecret) {
|
||||
httpError(rw, http.StatusForbidden, xerrors.Errorf("basic auth failed"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, ok := validateMW(rw, r)
|
||||
if !ok {
|
||||
httpError(rw, http.StatusForbidden, xerrors.Errorf("token validation failed"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code, err := f.hookRevokeToken()
|
||||
if err != nil {
|
||||
httpError(rw, code, xerrors.Errorf("hook err: %w", err))
|
||||
return
|
||||
}
|
||||
httpapi.Write(r.Context(), rw, code, "")
|
||||
}))
|
||||
|
||||
// There is almost no difference between this and /userinfo.
|
||||
// The main tweak is that this route is "mounted" vs "handle" because "/userinfo"
|
||||
// should be strict, and this one needs to handle sub routes.
|
||||
@@ -1474,12 +1523,16 @@ func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAu
|
||||
DisplayName: id,
|
||||
InstrumentedOAuth2Config: oauthCfg,
|
||||
ID: id,
|
||||
ClientID: f.clientID,
|
||||
ClientSecret: f.clientSecret,
|
||||
// No defaults for these fields by omitting the type
|
||||
Type: "",
|
||||
DisplayIcon: f.WellknownConfig().UserInfoURL,
|
||||
// Omit the /user for the validate so we can easily append to it when modifying
|
||||
// the cfg for advanced tests.
|
||||
ValidateURL: f.locked.IssuerURL().ResolveReference(&url.URL{Path: "/external-auth-validate/"}).String(),
|
||||
ValidateURL: f.locked.IssuerURL().ResolveReference(&url.URL{Path: "/external-auth-validate/"}).String(),
|
||||
RevokeURL: f.locked.IssuerURL().ResolveReference(&url.URL{Path: revokeTokenPath}).String(),
|
||||
RevokeTimeout: 1 * time.Second,
|
||||
DeviceAuth: &externalauth.DeviceAuth{
|
||||
Config: oauthCfg,
|
||||
ClientID: f.clientID,
|
||||
|
||||
+35
-11
@@ -85,20 +85,37 @@ func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
|
||||
// @ID delete-external-auth-user-link-by-id
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Git
|
||||
// @Success 200
|
||||
// @Produce json
|
||||
// @Param externalauth path string true "Git Provider ID" format(string)
|
||||
// @Success 200 {object} codersdk.DeleteExternalAuthByIDResponse
|
||||
// @Router /external-auth/{externalauth} [delete]
|
||||
func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) {
|
||||
config := httpmw.ExternalAuthParam(r)
|
||||
apiKey := httpmw.APIKey(r)
|
||||
ctx := r.Context()
|
||||
|
||||
err := api.Database.DeleteExternalAuthLink(ctx, database.DeleteExternalAuthLinkParams{
|
||||
link, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
|
||||
ProviderID: config.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(w)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to get external auth link during deletion.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.DeleteExternalAuthLink(ctx, database.DeleteExternalAuthLinkParams{
|
||||
ProviderID: config.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.ResourceNotFound(w)
|
||||
return
|
||||
}
|
||||
@@ -109,7 +126,13 @@ func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusOK, "OK")
|
||||
ok, err := config.RevokeToken(ctx, link)
|
||||
resp := codersdk.DeleteExternalAuthByIDResponse{TokenRevoked: ok}
|
||||
|
||||
if err != nil {
|
||||
resp.TokenRevocationError = err.Error()
|
||||
}
|
||||
httpapi.Write(ctx, w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Post external auth device by ID
|
||||
@@ -394,13 +417,14 @@ func ExternalAuthConfigs(auths []*externalauth.Config) []codersdk.ExternalAuthLi
|
||||
|
||||
func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvider {
|
||||
return codersdk.ExternalAuthLinkProvider{
|
||||
ID: cfg.ID,
|
||||
Type: cfg.Type,
|
||||
Device: cfg.DeviceAuth != nil,
|
||||
DisplayName: cfg.DisplayName,
|
||||
DisplayIcon: cfg.DisplayIcon,
|
||||
AllowRefresh: !cfg.NoRefresh,
|
||||
AllowValidate: cfg.ValidateURL != "",
|
||||
ID: cfg.ID,
|
||||
Type: cfg.Type,
|
||||
Device: cfg.DeviceAuth != nil,
|
||||
DisplayName: cfg.DisplayName,
|
||||
DisplayIcon: cfg.DisplayIcon,
|
||||
AllowRefresh: !cfg.NoRefresh,
|
||||
AllowValidate: cfg.ValidateURL != "",
|
||||
SupportsRevocation: cfg.RevokeURL != "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ const (
|
||||
// database for a failed refresh token. In rare cases, the error could be a large
|
||||
// HTML payload.
|
||||
failureReasonLimit = 400
|
||||
|
||||
// tokenRevocationTimeout timeout for requests to external oauth provider.
|
||||
tokenRevocationTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Config is used for authentication for Git operations.
|
||||
@@ -43,6 +46,9 @@ type Config struct {
|
||||
ID string
|
||||
// Type is the type of provider.
|
||||
Type string
|
||||
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
// DeviceAuth is set if the provider uses the device flow.
|
||||
DeviceAuth *DeviceAuth
|
||||
// DisplayName is the name of the provider to display to the user.
|
||||
@@ -69,6 +75,9 @@ type Config struct {
|
||||
// not be validated before being returned.
|
||||
ValidateURL string
|
||||
|
||||
RevokeURL string
|
||||
RevokeTimeout time.Duration
|
||||
|
||||
// Regex is a Regexp matched against URLs for
|
||||
// a Git clone. e.g. "Username for 'https://github.com':"
|
||||
// The regex would be `github\.com`..
|
||||
@@ -398,6 +407,83 @@ func (c *Config) AppInstallations(ctx context.Context, token string) ([]codersdk
|
||||
return installs, true, nil
|
||||
}
|
||||
|
||||
func (c *Config) RevokeToken(ctx context.Context, link database.ExternalAuthLink) (bool, error) {
|
||||
if c.RevokeURL == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, c.RevokeTimeout)
|
||||
defer cancel()
|
||||
req, err := c.TokenRevocationRequest(reqCtx, link)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
res, err := c.InstrumentedOAuth2Config.Do(ctx, promoauth.SourceRevoke, req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if c.TokenRevocationResponseOk(res) {
|
||||
return true, nil
|
||||
}
|
||||
return false, xerrors.Errorf("failed to revoke token: %d %s", res.StatusCode, string(body))
|
||||
}
|
||||
|
||||
func (c *Config) TokenRevocationRequest(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
|
||||
if c.Type == codersdk.EnhancedExternalAuthProviderGitHub.String() {
|
||||
return c.TokenRevocationRequestGitHub(ctx, link)
|
||||
}
|
||||
return c.TokenRevocationRequestRFC7009(ctx, link)
|
||||
}
|
||||
|
||||
func (c *Config) TokenRevocationRequestRFC7009(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
|
||||
p := url.Values{}
|
||||
p.Add("client_id", c.ClientID)
|
||||
p.Add("client_secret", c.ClientSecret)
|
||||
if link.OAuthRefreshToken != "" {
|
||||
p.Add("token_type_hint", "refresh_token")
|
||||
p.Add("token", link.OAuthRefreshToken)
|
||||
} else {
|
||||
p.Add("token_type_hint", "access_token")
|
||||
p.Add("token", link.OAuthAccessToken)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.RevokeURL, strings.NewReader(p.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", link.OAuthAccessToken))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Config) TokenRevocationRequestGitHub(ctx context.Context, link database.ExternalAuthLink) (*http.Request, error) {
|
||||
// GitHub doesn't follow RFC spec
|
||||
// https://docs.github.com/en/rest/apps/oauth-applications?apiVersion=2022-11-28#delete-an-app-authorization
|
||||
body := fmt.Sprintf("{\"access_token\":%q}", link.OAuthAccessToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.RevokeURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/vnd.github+json")
|
||||
req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Config) TokenRevocationResponseOk(res *http.Response) bool {
|
||||
// RFC spec on successful revocation returns 200, GitHub 204
|
||||
if c.Type == codersdk.EnhancedExternalAuthProviderGitHub.String() {
|
||||
return res.StatusCode == http.StatusNoContent
|
||||
}
|
||||
return res.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
type DeviceAuth struct {
|
||||
// Config is provided for the http client method.
|
||||
Config promoauth.InstrumentedOAuth2Config
|
||||
@@ -639,10 +725,14 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
|
||||
cfg := &Config{
|
||||
InstrumentedOAuth2Config: instrumented,
|
||||
ID: entry.ID,
|
||||
ClientID: entry.ClientID,
|
||||
ClientSecret: entry.ClientSecret,
|
||||
Regex: regex,
|
||||
Type: entry.Type,
|
||||
NoRefresh: entry.NoRefresh,
|
||||
ValidateURL: entry.ValidateURL,
|
||||
RevokeURL: entry.RevokeURL,
|
||||
RevokeTimeout: tokenRevocationTimeout,
|
||||
AppInstallationsURL: entry.AppInstallationsURL,
|
||||
AppInstallURL: entry.AppInstallURL,
|
||||
DisplayName: entry.DisplayName,
|
||||
@@ -807,6 +897,7 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
|
||||
AuthURL: "https://gitlab.com/oauth/authorize",
|
||||
TokenURL: "https://gitlab.com/oauth/token",
|
||||
ValidateURL: "https://gitlab.com/oauth/token/info",
|
||||
RevokeURL: "https://gitlab.com/oauth/revoke",
|
||||
DisplayName: "GitLab",
|
||||
DisplayIcon: "/icon/gitlab.svg",
|
||||
Regex: `^(https?://)?gitlab\.com(/.*)?$`,
|
||||
@@ -832,6 +923,7 @@ func gitlabDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCo
|
||||
AuthURL: au.ResolveReference(&url.URL{Path: "/oauth/authorize"}).String(),
|
||||
TokenURL: au.ResolveReference(&url.URL{Path: "/oauth/token"}).String(),
|
||||
ValidateURL: au.ResolveReference(&url.URL{Path: "/oauth/token/info"}).String(),
|
||||
RevokeURL: au.ResolveReference(&url.URL{Path: "/oauth/revoke"}).String(),
|
||||
Regex: fmt.Sprintf(`^(https?://)?%s(/.*)?$`, strings.ReplaceAll(au.Host, ".", `\.`)),
|
||||
}
|
||||
}
|
||||
@@ -973,6 +1065,7 @@ var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.External
|
||||
codersdk.EnhancedExternalAuthProviderSlack: {
|
||||
AuthURL: "https://slack.com/oauth/v2/authorize",
|
||||
TokenURL: "https://slack.com/api/oauth.v2.access",
|
||||
RevokeURL: "https://slack.com/api/auth.revoke",
|
||||
DisplayName: "Slack",
|
||||
DisplayIcon: "/icon/slack.svg",
|
||||
// See: https://api.slack.com/authentication/oauth-v2#exchanging
|
||||
|
||||
@@ -381,6 +381,150 @@ func TestRefreshToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevokeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RevokeTokenRFC_OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var link database.ExternalAuthLink
|
||||
var config *externalauth.Config
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenRFC(func() (int, error) {
|
||||
return http.StatusOK, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
require.NoError(t, err)
|
||||
require.True(t, revoked)
|
||||
})
|
||||
|
||||
t.Run("RevokeTokenRFC_WrongBearer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenRFC(func() (int, error) {
|
||||
return http.StatusOK, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
link.OAuthAccessToken += "wrong_token"
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "token validation failed")
|
||||
require.False(t, revoked)
|
||||
})
|
||||
|
||||
t.Run("RevokeTokenRFC_WrongURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenRFC(func() (int, error) {
|
||||
return http.StatusOK, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
config.RevokeURL = "%"
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "invalid URL escape")
|
||||
require.False(t, revoked)
|
||||
})
|
||||
|
||||
t.Run("RevokeTokenRFC_Timeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
revokeExited := make(chan bool, 1)
|
||||
testTimeout := make(chan bool, 1)
|
||||
handlerDone := make(chan bool)
|
||||
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
testTimeout <- true
|
||||
}()
|
||||
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenRFC(func() (int, error) {
|
||||
defer func() {
|
||||
handlerDone <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-testTimeout:
|
||||
t.Error("test timeout reached before context timeout")
|
||||
return http.StatusOK, nil
|
||||
case <-revokeExited:
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
}),
|
||||
oidctest.WithServing(),
|
||||
},
|
||||
})
|
||||
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
config.RevokeTimeout = time.Millisecond * 10
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
revokeExited <- true
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.False(t, revoked)
|
||||
_ = testutil.RequireReceive(ctx, t, handlerDone)
|
||||
})
|
||||
|
||||
t.Run("RevokeTokenGitHub_OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
clientID := "clientID"
|
||||
clientSecret := "clientSecret"
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenGitHub(func() (int, error) {
|
||||
return http.StatusNoContent, nil
|
||||
}),
|
||||
oidctest.WithStaticCredentials(clientID, clientSecret),
|
||||
oidctest.WithServing(),
|
||||
},
|
||||
})
|
||||
|
||||
config.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
|
||||
config.ClientID = clientID
|
||||
config.ClientSecret = clientSecret
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
require.NoError(t, err)
|
||||
require.True(t, revoked)
|
||||
})
|
||||
|
||||
t.Run("RevokeTokenGitHub_WrongAuth", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
clientID := "clientID"
|
||||
clientSecret := "clientSecret"
|
||||
fake, config, link := setupOauth2Test(t, testConfig{
|
||||
FakeIDPOpts: []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRevokeTokenGitHub(func() (int, error) {
|
||||
return http.StatusNoContent, nil
|
||||
}),
|
||||
oidctest.WithStaticCredentials(clientID, clientSecret),
|
||||
oidctest.WithServing(),
|
||||
},
|
||||
})
|
||||
|
||||
config.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
|
||||
config.ClientID = clientID + "bad"
|
||||
config.ClientSecret = clientSecret
|
||||
ctx := oidc.ClientContext(testutil.Context(t, testutil.WaitLong), fake.HTTPClient(nil))
|
||||
revoked, err := config.RevokeToken(ctx, link)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "basic auth failed")
|
||||
require.False(t, revoked)
|
||||
})
|
||||
}
|
||||
|
||||
func TestExchangeWithClientSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
instrument := promoauth.NewFactory(prometheus.NewRegistry())
|
||||
@@ -416,6 +560,53 @@ func TestExchangeWithClientSecret(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTokenRevocationResponseOk(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ghType := codersdk.EnhancedExternalAuthProviderGitHub.String()
|
||||
rfcType := codersdk.EnhancedExternalAuthProviderAzureDevops.String()
|
||||
tests := []struct {
|
||||
name string
|
||||
conf *externalauth.Config
|
||||
resp http.Response
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "GH_bad",
|
||||
conf: &externalauth.Config{Type: ghType},
|
||||
resp: http.Response{StatusCode: http.StatusOK},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "GH_ok",
|
||||
conf: &externalauth.Config{Type: ghType},
|
||||
resp: http.Response{StatusCode: http.StatusNoContent},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "RFC_ok",
|
||||
conf: &externalauth.Config{Type: rfcType},
|
||||
resp: http.Response{StatusCode: http.StatusOK},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "RFC_bad",
|
||||
conf: &externalauth.Config{Type: rfcType},
|
||||
resp: http.Response{StatusCode: http.StatusNoContent},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := tc.conf.TokenRevocationResponseOk(&tc.resp)
|
||||
if tc.want != got {
|
||||
t.Errorf("unexpected response success, got: %v want: %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertYAML(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -494,6 +685,17 @@ func TestConvertYAML(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternal-auth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
|
||||
})
|
||||
|
||||
t.Run("RevokeTimeoutSet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
configs, err := externalauth.ConvertConfig(instrument, []codersdk.ExternalAuthConfig{{
|
||||
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
|
||||
ClientID: "id",
|
||||
ClientSecret: "secret",
|
||||
}}, &url.URL{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 10*time.Second, configs[0].RevokeTimeout)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConstantQueryParams verifies a constant query parameter can be set in the
|
||||
@@ -594,11 +796,16 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
|
||||
)
|
||||
|
||||
f := promoauth.NewFactory(prometheus.NewRegistry())
|
||||
cid, cs := fake.AppCredentials()
|
||||
config := &externalauth.Config{
|
||||
InstrumentedOAuth2Config: f.New("test-oauth2",
|
||||
fake.OIDCConfig(t, nil, settings.CoderOIDCConfigOpts...)),
|
||||
ID: providerID,
|
||||
ValidateURL: fake.WellknownConfig().UserInfoURL,
|
||||
ID: providerID,
|
||||
ClientID: cid,
|
||||
ClientSecret: cs,
|
||||
ValidateURL: fake.WellknownConfig().UserInfoURL,
|
||||
RevokeURL: fake.WellknownConfig().RevokeURL,
|
||||
RevokeTimeout: 1 * time.Second,
|
||||
}
|
||||
settings.ExternalAuthOpt(config)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||
@@ -158,9 +160,29 @@ func TestExternalAuthManagement(t *testing.T) {
|
||||
t.Parallel()
|
||||
const githubID = "fake-github"
|
||||
const gitlabID = "fake-gitlab"
|
||||
const slackID = "fake-slack"
|
||||
const azureID = "fake-azure"
|
||||
ghRevokeCalled := false
|
||||
slRevokeCalled := false
|
||||
azRevokeCalled := false
|
||||
|
||||
github := oidctest.NewFakeIDP(t, oidctest.WithServing())
|
||||
ghRevoke := func() (int, error) {
|
||||
ghRevokeCalled = true
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
slRevoke := func() (int, error) {
|
||||
slRevokeCalled = true
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
azRevoke := func() (int, error) {
|
||||
azRevokeCalled = true
|
||||
return http.StatusForbidden, xerrors.New("some error")
|
||||
}
|
||||
|
||||
github := oidctest.NewFakeIDP(t, oidctest.WithServing(), oidctest.WithRevokeTokenGitHub(ghRevoke))
|
||||
gitlab := oidctest.NewFakeIDP(t, oidctest.WithServing())
|
||||
slack := oidctest.NewFakeIDP(t, oidctest.WithServing(), oidctest.WithRevokeTokenRFC(slRevoke))
|
||||
azure := oidctest.NewFakeIDP(t, oidctest.WithServing(), oidctest.WithRevokeTokenRFC(azRevoke))
|
||||
|
||||
owner := coderdtest.New(t, &coderdtest.Options{
|
||||
ExternalAuthConfigs: []*externalauth.Config{
|
||||
@@ -170,6 +192,13 @@ func TestExternalAuthManagement(t *testing.T) {
|
||||
gitlab.ExternalAuthConfig(t, gitlabID, nil, func(cfg *externalauth.Config) {
|
||||
cfg.Type = codersdk.EnhancedExternalAuthProviderGitLab.String()
|
||||
}),
|
||||
slack.ExternalAuthConfig(t, slackID, nil, func(cfg *externalauth.Config) {
|
||||
cfg.Type = codersdk.EnhancedExternalAuthProviderSlack.String()
|
||||
cfg.RevokeURL = ""
|
||||
}),
|
||||
azure.ExternalAuthConfig(t, azureID, nil, func(cfg *externalauth.Config) {
|
||||
cfg.Type = codersdk.EnhancedExternalAuthProviderAzureDevopsEntra.String()
|
||||
}),
|
||||
},
|
||||
})
|
||||
ownerUser := coderdtest.CreateFirstUser(t, owner)
|
||||
@@ -180,25 +209,47 @@ func TestExternalAuthManagement(t *testing.T) {
|
||||
// List auths without any links.
|
||||
list, err := client.ListExternalAuths(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list.Providers, 2)
|
||||
require.Len(t, list.Providers, 4)
|
||||
require.Len(t, list.Links, 0)
|
||||
|
||||
// Log into github
|
||||
// Log into github and slack
|
||||
github.ExternalLogin(t, client)
|
||||
slack.ExternalLogin(t, client)
|
||||
azure.ExternalLogin(t, client)
|
||||
|
||||
list, err = client.ListExternalAuths(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list.Providers, 2)
|
||||
require.Len(t, list.Links, 1)
|
||||
require.Equal(t, list.Links[0].ProviderID, githubID)
|
||||
require.Len(t, list.Providers, 4)
|
||||
require.Len(t, list.Links, 3)
|
||||
require.True(t, slices.ContainsFunc(list.Links, func(l codersdk.ExternalAuthLink) bool { return l.ProviderID == githubID }))
|
||||
require.True(t, slices.ContainsFunc(list.Links, func(l codersdk.ExternalAuthLink) bool { return l.ProviderID == slackID }))
|
||||
require.True(t, slices.ContainsFunc(list.Links, func(l codersdk.ExternalAuthLink) bool { return l.ProviderID == azureID }))
|
||||
require.False(t, ghRevokeCalled)
|
||||
require.False(t, slRevokeCalled)
|
||||
require.False(t, azRevokeCalled)
|
||||
|
||||
// Unlink
|
||||
err = client.UnlinkExternalAuthByID(ctx, githubID)
|
||||
r, err := client.UnlinkExternalAuthByID(ctx, githubID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, r.TokenRevoked)
|
||||
require.Empty(t, r.TokenRevocationError)
|
||||
require.True(t, ghRevokeCalled)
|
||||
|
||||
r, err = client.UnlinkExternalAuthByID(ctx, slackID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, r.TokenRevoked)
|
||||
require.Empty(t, r.TokenRevocationError)
|
||||
require.False(t, slRevokeCalled)
|
||||
|
||||
r, err = client.UnlinkExternalAuthByID(ctx, azureID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, r.TokenRevoked)
|
||||
require.Contains(t, r.TokenRevocationError, "some error")
|
||||
require.True(t, azRevokeCalled)
|
||||
|
||||
list, err = client.ListExternalAuths(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list.Providers, 2)
|
||||
require.Len(t, list.Providers, 4)
|
||||
require.Len(t, list.Links, 0)
|
||||
})
|
||||
t.Run("RefreshAllProviders", func(t *testing.T) {
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
SourceTokenSource Oauth2Source = "TokenSource"
|
||||
SourceAppInstallations Oauth2Source = "AppInstallations"
|
||||
SourceAuthorizeDevice Oauth2Source = "AuthorizeDevice"
|
||||
SourceRevoke Oauth2Source = "Revoke"
|
||||
|
||||
SourceGitAPIAuthUser Oauth2Source = "GitAPIAuthUser"
|
||||
SourceGitAPIListEmails Oauth2Source = "GitAPIListEmails"
|
||||
|
||||
Reference in New Issue
Block a user