diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a1c7ea..fd3d6d3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## Breaking Changes ## Changes since v7.1.3 +- [#1238](https://github.com/oauth2-proxy/oauth2-proxy/pull/1238) Added ADFS provider (@samirachoadi) - [#1227](https://github.com/oauth2-proxy/oauth2-proxy/pull/1227) Fix Refresh Session not working for multiple cookies (@rishi1111) - [#1063](https://github.com/oauth2-proxy/oauth2-proxy/pull/1063) Add Redis lock feature to lock persistent sessions (@Bibob7) - [#1108](https://github.com/oauth2-proxy/oauth2-proxy/pull/1108) Add alternative ways to generate cookie secrets to docs (@JoelSpeed) diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index ea2466d6..ca490b34 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -101,6 +101,16 @@ You must remove these options before starting OAuth2 Proxy with `--alpha-config` ## Configuration Reference +### ADFSOptions + +(**Appears on:** [Provider](#provider)) + + + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `skipScope` | _bool_ | Skip adding the scope parameter in login request
Default value is 'false' | + ### AlphaOptions AlphaOptions contains alpha structured configuration options. @@ -284,6 +294,7 @@ Provider holds all configuration for a single provider | `clientSecretFile` | _string_ | ClientSecretFile is the name of the file
containing the OAuth Client Secret, it will be used if ClientSecret is not set. | | `keycloakConfig` | _[KeycloakOptions](#keycloakoptions)_ | KeycloakConfig holds all configurations for Keycloak provider. | | `azureConfig` | _[AzureOptions](#azureoptions)_ | AzureConfig holds all configurations for Azure provider. | +| `ADFSConfig` | _[ADFSOptions](#adfsoptions)_ | ADFSConfig holds all configurations for ADFS provider. | | `bitbucketConfig` | _[BitbucketOptions](#bitbucketoptions)_ | BitbucketConfig holds all configurations for Bitbucket provider. | | `githubConfig` | _[GitHubOptions](#githuboptions)_ | GitHubConfig holds all configurations for GitHubC provider. | | `gitlabConfig` | _[GitLabOptions](#gitlaboptions)_ | GitLabConfig holds all configurations for GitLab provider. | @@ -297,7 +308,7 @@ Provider holds all configuration for a single provider | `loginURL` | _string_ | LoginURL is the authentication endpoint | | `redeemURL` | _string_ | RedeemURL is the token redemption endpoint | | `profileURL` | _string_ | ProfileURL is the profile access endpoint | -| `resource` | _string_ | ProtectedResource is the resource that is protected (Azure AD only) | +| `resource` | _string_ | ProtectedResource is the resource that is protected (Azure AD and ADFS only) | | `validateURL` | _string_ | ValidateURL is the access token validation endpoint | | `scope` | _string_ | Scope is the OAuth scope specification | | `prompt` | _string_ | Prompt is OIDC prompt | diff --git a/docs/docs/configuration/auth.md b/docs/docs/configuration/auth.md index 6a667da0..0673c295 100644 --- a/docs/docs/configuration/auth.md +++ b/docs/docs/configuration/auth.md @@ -9,6 +9,7 @@ Valid providers are : - [Google](#google-auth-provider) _default_ - [Azure](#azure-auth-provider) +- [ADFS](#adfs-auth-provider) - [Facebook](#facebook-auth-provider) - [GitHub](#github-auth-provider) - [Keycloak](#keycloak-auth-provider) @@ -88,6 +89,21 @@ Note: The user is checked against the group members list on initial authenticati Note: When using the Azure Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) should resolve this. +### ADFS Auth Provider + +1. Open the ADFS administration console on your Windows Server and add a new Application Group +2. Provide a name for the integration, select Server Application from the Standalone applications section and click Next +3. Follow the wizard to get the client-id, client-secret and configure the application credentials +4. Configure the proxy with + +``` + --provider=adfs + --client-id= + --client-secret= +``` + +Note: When using the ADFS Auth provider with nginx and the cookie session store you may find the cookie is too large and doesn't get passed through correctly. Increasing the proxy_buffer_size in nginx or implementing the [redis session storage](sessions.md#redis-storage) should resolve this. + ### Facebook Auth Provider 1. Create a new FB App from diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 3b8d0da4..5efa6f20 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -21,6 +21,8 @@ type Provider struct { KeycloakConfig KeycloakOptions `json:"keycloakConfig,omitempty"` // AzureConfig holds all configurations for Azure provider. AzureConfig AzureOptions `json:"azureConfig,omitempty"` + // ADFSConfig holds all configurations for ADFS provider. + ADFSConfig ADFSOptions `json:"ADFSConfig,omitempty"` // BitbucketConfig holds all configurations for Bitbucket provider. BitbucketConfig BitbucketOptions `json:"bitbucketConfig,omitempty"` // GitHubConfig holds all configurations for GitHubC provider. @@ -55,7 +57,7 @@ type Provider struct { RedeemURL string `json:"redeemURL,omitempty"` // ProfileURL is the profile access endpoint ProfileURL string `json:"profileURL,omitempty"` - // ProtectedResource is the resource that is protected (Azure AD only) + // ProtectedResource is the resource that is protected (Azure AD and ADFS only) ProtectedResource string `json:"resource,omitempty"` // ValidateURL is the access token validation endpoint ValidateURL string `json:"validateURL,omitempty"` @@ -84,6 +86,12 @@ type AzureOptions struct { Tenant string `json:"tenant,omitempty"` } +type ADFSOptions struct { + // Skip adding the scope parameter in login request + // Default value is 'false' + SkipScope bool `json:"skipScope,omitempty"` +} + type BitbucketOptions struct { // Team sets restrict logins to members of this team Team string `json:"team,omitempty"` diff --git a/pkg/validation/options.go b/pkg/validation/options.go index 2e5b884d..73f14b72 100644 --- a/pkg/validation/options.go +++ b/pkg/validation/options.go @@ -236,6 +236,8 @@ func parseProviderInfo(o *options.Options, msgs []string) []string { switch p := o.GetProvider().(type) { case *providers.AzureProvider: p.Configure(o.Providers[0].AzureConfig.Tenant) + case *providers.ADFSProvider: + p.Configure(o.Providers[0].ADFSConfig.SkipScope) case *providers.GitHubProvider: p.SetOrgTeam(o.Providers[0].GitHubConfig.Org, o.Providers[0].GitHubConfig.Team) p.SetRepo(o.Providers[0].GitHubConfig.Repo, o.Providers[0].GitHubConfig.Token) diff --git a/providers/adfs.go b/providers/adfs.go new file mode 100644 index 00000000..c626bd09 --- /dev/null +++ b/providers/adfs.go @@ -0,0 +1,100 @@ +package providers + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" +) + +// ADFSProvider represents an ADFS based Identity Provider +type ADFSProvider struct { + *OIDCProvider + SkipScope bool +} + +var _ Provider = (*ADFSProvider)(nil) + +const ( + ADFSProviderName = "ADFS" + ADFSDefaultScope = "openid email profile" + ADFSSkipScope = false +) + +// NewADFSProvider initiates a new ADFSProvider +func NewADFSProvider(p *ProviderData) *ADFSProvider { + + p.setProviderDefaults(providerDefaults{ + name: ADFSProviderName, + scope: ADFSDefaultScope, + }) + + if p.ProtectedResource != nil && p.ProtectedResource.String() != "" { + resource := p.ProtectedResource.String() + if !strings.HasSuffix(resource, "/") { + resource += "/" + } + + if p.Scope != "" && !strings.HasPrefix(p.Scope, resource) { + p.Scope = resource + p.Scope + } + } + + return &ADFSProvider{ + OIDCProvider: &OIDCProvider{ + ProviderData: p, + SkipNonce: true, + }, + SkipScope: ADFSSkipScope, + } +} + +// Configure defaults the ADFSProvider configuration options +func (p *ADFSProvider) Configure(skipScope bool) { + p.SkipScope = skipScope +} + +// GetLoginURL Override to double encode the state parameter. If not query params are lost +// More info here: https://docs.microsoft.com/en-us/powerapps/maker/portals/configure/configure-saml2-settings +func (p *ADFSProvider) GetLoginURL(redirectURI, state, nonce string) string { + extraParams := url.Values{} + if !p.SkipNonce { + extraParams.Add("nonce", nonce) + } + loginURL := makeLoginURL(p.Data(), redirectURI, url.QueryEscape(state), extraParams) + if p.SkipScope { + q := loginURL.Query() + q.Del("scope") + loginURL.RawQuery = q.Encode() + } + return loginURL.String() +} + +// EnrichSession to add email +func (p *ADFSProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + if s.Email != "" { + return nil + } + + idToken, err := p.Verifier.Verify(ctx, s.IDToken) + if err != nil { + return err + } + + p.EmailClaim = "upn" + c, err := p.getClaims(idToken) + + if err != nil { + return fmt.Errorf("couldn't extract claims from id_token (%v)", err) + } + s.Email = c.Email + + if s.Email == "" { + err = errors.New("email not set") + } + + return err +} diff --git a/providers/adfs_test.go b/providers/adfs_test.go new file mode 100644 index 00000000..4c8764bd --- /dev/null +++ b/providers/adfs_test.go @@ -0,0 +1,205 @@ +package providers + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strings" + + "github.com/coreos/go-oidc" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" +) + +type fakeADFSJwks struct{} + +func (fakeADFSJwks) VerifySignature(_ context.Context, jwt string) (payload []byte, err error) { + decodeString, err := base64.RawURLEncoding.DecodeString(strings.Split(jwt, ".")[1]) + if err != nil { + return nil, err + } + return decodeString, nil +} + +func testADFSProvider(hostname string) *ADFSProvider { + + o := oidc.NewVerifier( + "https://issuer.example.com", + fakeADFSJwks{}, + &oidc.Config{ClientID: "https://test.myapp.com"}, + ) + + p := NewADFSProvider(&ProviderData{ + ProviderName: "", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: "", + Verifier: o, + }) + + if hostname != "" { + updateURL(p.Data().LoginURL, hostname) + updateURL(p.Data().RedeemURL, hostname) + updateURL(p.Data().ProfileURL, hostname) + updateURL(p.Data().ValidateURL, hostname) + } + + return p +} + +func testADFSBackend() *httptest.Server { + + authResponse := ` + { + "access_token": "my_access_token", + "id_token": "my_id_token", + "refresh_token": "my_refresh_token" + } + ` + userInfo := ` + { + "email": "samiracho@email.com" + } + ` + + refreshResponse := `{ "access_token": "new_some_access_token", "refresh_token": "new_some_refresh_token", "expires_in": "32693148245", "id_token": "new_some_id_token" }` + + authHeader := "Bearer adfs_access_token" + + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/adfs/oauth2/authorize": + w.WriteHeader(200) + w.Write([]byte(authResponse)) + case "/adfs/oauth2/refresh": + w.WriteHeader(200) + w.Write([]byte(refreshResponse)) + case "/adfs/oauth2/userinfo": + if r.Header["Authorization"][0] == authHeader { + w.WriteHeader(200) + w.Write([]byte(userInfo)) + } else { + w.WriteHeader(401) + } + default: + w.WriteHeader(200) + } + })) +} + +var _ = Describe("ADFS Provider Tests", func() { + var p *ADFSProvider + var b *httptest.Server + + BeforeEach(func() { + b = testADFSBackend() + + bURL, err := url.Parse(b.URL) + Expect(err).To(BeNil()) + + p = testADFSProvider(bURL.Host) + }) + + AfterEach(func() { + b.Close() + }) + + Context("New Provider Init", func() { + It("uses defaults", func() { + providerData := NewADFSProvider(&ProviderData{}).Data() + Expect(providerData.ProviderName).To(Equal("ADFS")) + Expect(providerData.Scope).To(Equal("openid email profile")) + }) + }) + + Context("with bad token", func() { + It("should trigger an error", func() { + session := &sessions.SessionState{AccessToken: "unexpected_adfs_access_token", IDToken: "malformed_token"} + err := p.EnrichSession(context.Background(), session) + Expect(err).NotTo(BeNil()) + }) + }) + + Context("with valid token", func() { + It("should not throw an error", func() { + p.EmailClaim = "email" + rawIDToken, _ := newSignedTestIDToken(defaultIDToken) + idToken, err := p.Verifier.Verify(context.Background(), rawIDToken) + Expect(err).To(BeNil()) + session, err := p.buildSessionFromClaims(idToken) + session.IDToken = rawIDToken + Expect(err).To(BeNil()) + err = p.EnrichSession(context.Background(), session) + Expect(session.Email).To(Equal("janed@me.com")) + Expect(err).To(BeNil()) + }) + }) + + Context("with skipScope enabled", func() { + It("should not include parameter scope", func() { + resource, _ := url.Parse("http://example.com") + p := NewADFSProvider(&ProviderData{ + ProtectedResource: resource, + Scope: "", + }) + p.SkipScope = true + + result := p.GetLoginURL("https://example.com/adfs/oauth2/", "", "") + Expect(result).NotTo(ContainSubstring("scope=")) + }) + }) + + Context("With resource parameter", func() { + type scopeTableInput struct { + resource string + scope string + expectedScope string + } + + DescribeTable("should return expected results", + func(in scopeTableInput) { + resource, _ := url.Parse(in.resource) + p := NewADFSProvider(&ProviderData{ + ProtectedResource: resource, + Scope: in.scope, + }) + + Expect(p.Data().Scope).To(Equal(in.expectedScope)) + result := p.GetLoginURL("https://example.com/adfs/oauth2/", "", "") + Expect(result).To(ContainSubstring("scope=" + url.QueryEscape(in.expectedScope))) + }, + Entry("should add slash", scopeTableInput{ + resource: "http://resource.com", + scope: "openid", + expectedScope: "http://resource.com/openid", + }), + Entry("shouldn't add extra slash", scopeTableInput{ + resource: "http://resource.com/", + scope: "openid", + expectedScope: "http://resource.com/openid", + }), + Entry("should add default scopes with resource", scopeTableInput{ + resource: "http://resource.com/", + scope: "", + expectedScope: "http://resource.com/openid email profile", + }), + Entry("should add default scopes", scopeTableInput{ + resource: "", + scope: "", + expectedScope: "openid email profile", + }), + Entry("shouldn't add resource if already in scopes", scopeTableInput{ + resource: "http://resource.com", + scope: "http://resource.com/openid", + expectedScope: "http://resource.com/openid", + }), + ) + }) +}) diff --git a/providers/providers.go b/providers/providers.go index 498d1db0..0340c420 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -33,6 +33,8 @@ func New(provider string, p *ProviderData) Provider { return NewKeycloakProvider(p) case "azure": return NewAzureProvider(p) + case "adfs": + return NewADFSProvider(p) case "gitlab": return NewGitLabProvider(p) case "oidc":