- Notifications
You must be signed in to change notification settings - Fork1.1k
feat: implement oauth2 RFC 7009 token revocation endpoint#20362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
b5359c11605377090beba94dc46e7110ef14c9ccde822b6775603be80d1fc1ddaf8ce3f5e7136File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -985,6 +985,16 @@ func New(options *Options) *API { | ||
| r.Post("/", api.postOAuth2ProviderAppToken()) | ||
| }) | ||
| // RFC 7009 Token Revocation Endpoint | ||
| r.Route("/revoke", func(r chi.Router) { | ||
Emyrk marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| r.Use( | ||
| // RFC 7009 endpoint uses OAuth2 client authentication, not API key | ||
| httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)), | ||
| ) | ||
| // POST /revoke is the standard OAuth2 token revocation endpoint per RFC 7009 | ||
| r.Post("/", api.revokeOAuth2Token()) | ||
| }) | ||
| // RFC 7591 Dynamic Client Registration - Public endpoint | ||
| r.Post("/register", api.postOAuth2ClientRegistration()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -446,6 +446,34 @@ var ( | ||
| Scope: rbac.ScopeAll, | ||
| }.WithCachedASTValue() | ||
| subjectSystemOAuth2 = rbac.Subject{ | ||
| Type: rbac.SubjectTypeSystemOAuth, | ||
| FriendlyName: "System OAuth2", | ||
| ID: uuid.Nil.String(), | ||
| Roles: rbac.Roles([]rbac.Role{ | ||
| { | ||
| Identifier: rbac.RoleIdentifier{Name: "system-oauth2"}, | ||
| DisplayName: "System OAuth2", | ||
| Site: rbac.Permissions(map[string][]policy.Action{ | ||
| // OAuth2 resources - full CRUD permissions | ||
| rbac.ResourceOauth2App.Type: rbac.ResourceOauth2App.AvailableActions(), | ||
| rbac.ResourceOauth2AppSecret.Type: rbac.ResourceOauth2AppSecret.AvailableActions(), | ||
| rbac.ResourceOauth2AppCodeToken.Type: rbac.ResourceOauth2AppCodeToken.AvailableActions(), | ||
| // API key permissions needed for OAuth2 token revocation | ||
| rbac.ResourceApiKey.Type: {policy.ActionRead, policy.ActionDelete}, | ||
Emyrk marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| // Minimal read permissions that might be needed for OAuth2 operations | ||
| rbac.ResourceUser.Type: {policy.ActionRead}, | ||
| rbac.ResourceOrganization.Type: {policy.ActionRead}, | ||
| }), | ||
| User: []rbac.Permission{}, | ||
| ByOrgID: map[string]rbac.OrgPermissions{}, | ||
| }, | ||
| }), | ||
| Scope: rbac.ScopeAll, | ||
| }.WithCachedASTValue() | ||
| subjectSystemReadProvisionerDaemons = rbac.Subject{ | ||
| Type: rbac.SubjectTypeSystemReadProvisionerDaemons, | ||
| FriendlyName: "Provisioner Daemons Reader", | ||
| @@ -643,6 +671,12 @@ func AsSystemRestricted(ctx context.Context) context.Context { | ||
| return As(ctx, subjectSystemRestricted) | ||
| } | ||
| // AsSystemOAuth2 returns a context with an actor that has permissions | ||
| // required for OAuth2 provider operations (token revocation, device codes, registration). | ||
| func AsSystemOAuth2(ctx context.Context) context.Context { | ||
| return As(ctx, subjectSystemOAuth2) | ||
| } | ||
| // AsSystemReadProvisionerDaemons returns a context with an actor that has permissions | ||
| // to read provisioner daemons. | ||
| func AsSystemReadProvisionerDaemons(ctx context.Context) context.Context { | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -720,7 +720,7 @@ func TestOAuth2ProviderRevoke(t *testing.T) { | ||
| }, | ||
| }, | ||
| { | ||
| name: "DeleteApp", | ||
| fn: func(ctx context.Context, client *codersdk.Client, s exchangeSetup) { | ||
| err := client.RevokeOAuth2ProviderApp(ctx, s.app.ID) | ||
| require.NoError(t, err) | ||
| @@ -1603,5 +1603,80 @@ func TestOAuth2RegistrationAccessToken(t *testing.T) { | ||
| }) | ||
| } | ||
| // TestOAuth2CoderClient verfies a codersdk client can be used with an oauth client. | ||
| func TestOAuth2CoderClient(t *testing.T) { | ||
| t.Parallel() | ||
| owner := coderdtest.New(t, nil) | ||
| first := coderdtest.CreateFirstUser(t, owner) | ||
| // Setup an oauth app | ||
| ctx := testutil.Context(t, testutil.WaitLong) | ||
| app, err := owner.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{ | ||
| Name: "new-app", | ||
| CallbackURL: "http://localhost", | ||
| }) | ||
| require.NoError(t, err) | ||
| appsecret, err := owner.PostOAuth2ProviderAppSecret(ctx, app.ID) | ||
| require.NoError(t, err) | ||
Emyrk marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| cfg := &oauth2.Config{ | ||
| ClientID: app.ID.String(), | ||
| ClientSecret: appsecret.ClientSecretFull, | ||
| Endpoint: oauth2.Endpoint{ | ||
| AuthURL: app.Endpoints.Authorization, | ||
| DeviceAuthURL: app.Endpoints.DeviceAuth, | ||
| TokenURL: app.Endpoints.Token, | ||
| AuthStyle: oauth2.AuthStyleInParams, | ||
| }, | ||
| RedirectURL: app.CallbackURL, | ||
| Scopes: []string{}, | ||
| } | ||
| // Make a new user | ||
| client, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID) | ||
| // Do an OAuth2 token exchange and get a new client with an oauth token | ||
| state := uuid.NewString() | ||
| // Get an OAuth2 code for a token exchange | ||
| code, err := oidctest.OAuth2GetCode( | ||
| cfg.AuthCodeURL(state), | ||
| func(req *http.Request) (*http.Response, error) { | ||
| // Change to POST to simulate the form submission | ||
| req.Method = http.MethodPost | ||
| // Prevent automatic redirect following | ||
| client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { | ||
| return http.ErrUseLastResponse | ||
| } | ||
| return client.Request(ctx, req.Method, req.URL.String(), nil) | ||
| }, | ||
| ) | ||
| require.NoError(t, err) | ||
| token, err := cfg.Exchange(ctx, code) | ||
| require.NoError(t, err) | ||
| // Use the oauth client's authentication | ||
| // TODO: The SDK could probably support this with a better syntax/api. | ||
Emyrk marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)) | ||
| usingOauth := codersdk.New(owner.URL) | ||
| usingOauth.HTTPClient = oauthClient | ||
| me, err := usingOauth.User(ctx, codersdk.Me) | ||
| require.NoError(t, err) | ||
| require.Equal(t, user.ID, me.ID) | ||
| // Revoking the refresh token should prevent further access | ||
| // Revoking the refresh also invalidates the associated access token. | ||
| err = usingOauth.RevokeOAuth2Token(ctx, app.ID, token.RefreshToken) | ||
| require.NoError(t, err) | ||
| _, err = usingOauth.User(ctx, codersdk.Me) | ||
| require.Error(t, err) | ||
| } | ||
| // NOTE: OAuth2 client registration validation tests have been migrated to | ||
| // oauth2provider/validation_test.go for better separation of concerns | ||
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.