Skip to content
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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): basic support for openid-connect authentication provider #5393

Merged
merged 8 commits into from
Aug 27, 2020
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/content/docs/integrations/openid-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: OpenID-Connect Authentication
main_menu: true
card:
name: authentication
---

The OpenID-Connect Integration have to be configured on your CDS by a CDS Administrator.

This integration allows you to delegate users authetication to an OpenID-Connect third party like [Keycloak](https://www.keycloak.org/getting-started) or [Hydra](https://github.com/ory/hydra)

## How to configure OpenID-Connect Authentication integration

Edit the toml file:

- section `[api.auth.oidc]`
- enable the signin with `enabled = true`
- if you want to disable signup, set `signupDisabled = true`

```toml
[api.auth.oidc]
clientId = "YOUR CLIENT ID"
clientSecret = "YOUR CLIENT SECRET"
enabled = true
signupDisabled = false
url = "http[s]://<OIDC HOST>:<PORT>/auth/realms/<YOUR REALM>"
```

For example :
```toml
[api.auth.oidc]
clientId = "cds_client"
clientSecret = "6ebf3c3f-6f0b-4326-bebd-05fd472a90ec"
enabled = true
signupDisabled = false
url = "http://openid-connect.myorg.com:8080/auth/realms/cds"
```
22 changes: 21 additions & 1 deletion engine/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/ovh/cds/engine/api/authentication/gitlab"
"github.com/ovh/cds/engine/api/authentication/ldap"
"github.com/ovh/cds/engine/api/authentication/local"
"github.com/ovh/cds/engine/api/authentication/oidc"
"github.com/ovh/cds/engine/api/bootstrap"
"github.com/ovh/cds/engine/api/broadcast"
"github.com/ovh/cds/engine/api/database/gorpmapping"
Expand Down Expand Up @@ -136,7 +137,14 @@ type Configuration struct {
ApplicationID string `toml:"applicationID" json:"-" comment:"GitLab OAuth Application ID"`
Secret string `toml:"secret" json:"-" comment:"GitLab OAuth Application Secret"`
} `toml:"gitlab" json:"gitlab" comment:"#######\n CDS <-> GitLab Auth. Documentation on https://ovh.github.io/cds/docs/integrations/gitlab/gitlab_authentication/ \n######"`
} `toml:"auth" comment:"##############################\n CDS Authentication Settings#\n#############################" json:"auth"`
OIDC struct {
Enabled bool `toml:"enabled" default:"false" json:"enabled"`
SignupDisabled bool `toml:"signupDisabled" default:"false" json:"signupDisabled"`
URL string `toml:"url" json:"url" default:"" comment:"Open ID connect config URL"`
ClientID string `toml:"clientId" json:"-" comment:"OIDC Client ID"`
ClientSecret string `toml:"clientSecret" json:"-" comment:"OIDC Client Secret"`
} `toml:"oidc" json:"oidc" comment:"#######\n CDS <-> Open ID Connect Auth. Documentation on https://ovh.github.io/cds/docs/integrations/openid-connect/ \n######"`
} `toml:"auth" comment:"##############################\n CDS Authentication Settings# \n#############################" json:"auth"`
SMTP struct {
Disable bool `toml:"disable" default:"true" json:"disable" comment:"Set to false to enable the internal SMTP client"`
Host string `toml:"host" json:"host" comment:"smtp host"`
Expand Down Expand Up @@ -582,6 +590,18 @@ func (a *API) Serve(ctx context.Context) error {
a.Config.Auth.Gitlab.Secret,
)
}
if a.Config.Auth.OIDC.Enabled {
a.AuthenticationDrivers[sdk.ConsumerOIDC], err = oidc.NewDriver(
a.Config.Auth.OIDC.SignupDisabled,
a.Config.URL.UI,
a.Config.Auth.OIDC.URL,
a.Config.Auth.OIDC.ClientID,
a.Config.Auth.OIDC.ClientSecret,
)
if err != nil {
return err
}
}

if a.Config.Auth.CorporateSSO.Enabled {
driverConfig := corpsso.Config{
Expand Down
140 changes: 140 additions & 0 deletions engine/api/authentication/oidc/openid.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package oidc

import (
"context"
"errors"
"fmt"
"net/http"
"time"

"github.com/ovh/cds/engine/api/authentication"

oidc "github.com/coreos/go-oidc"
"github.com/ovh/cds/sdk"
"golang.org/x/oauth2"
)

var _ sdk.AuthDriverWithRedirect = (*authDriver)(nil)
var _ sdk.AuthDriverWithSigninStateToken = (*authDriver)(nil)

// NewDriver returns a new OIDC auth driver for given config.
func NewDriver(signupDisabled bool, cdsURL, url, clientID, clientSecret string) (sdk.AuthDriver, error) {
provider, err := oidc.NewProvider(context.Background(), url)
if err != nil {
return nil, sdk.WrapError(err, "Failed to initialize OIDC driver")
}
// Configure an OpenID Connect aware OAuth2 client.
oauth2Config := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: fmt.Sprintf("%s/auth/callback/%s", cdsURL, sdk.ConsumerOIDC),
// Discovery returns the OAuth2 endpoints.
Endpoint: provider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
oidcConfig := &oidc.Config{
ClientID: clientID,
}
verifier := provider.Verifier(oidcConfig)

return &authDriver{
signupDisabled: signupDisabled,
cdsURL: cdsURL,
OAuth2Config: oauth2Config,
Verifier: verifier,
}, nil
}

type authDriver struct {
signupDisabled bool
cdsURL string
OAuth2Config oauth2.Config
Verifier *oidc.IDTokenVerifier
}

func (d authDriver) GetManifest() sdk.AuthDriverManifest {
return sdk.AuthDriverManifest{
Type: sdk.ConsumerOIDC,
SignupDisabled: d.signupDisabled,
}
}

func (d authDriver) GetSigninURI(signinState sdk.AuthSigninConsumerToken) (sdk.AuthDriverSigningRedirect, error) {
// Generate a new state value for the auth signin request
jws, err := authentication.NewDefaultSigninStateToken(signinState.Origin,
signinState.RedirectURI, signinState.IsFirstConnection)
if err != nil {
return sdk.AuthDriverSigningRedirect{}, err
}

var result = sdk.AuthDriverSigningRedirect{
Method: http.MethodGet,
URL: d.OAuth2Config.AuthCodeURL(jws),
}

return result, nil
}

func (d authDriver) GetSessionDuration() time.Duration {
return time.Hour * 24 * 30 // 1 month session
}

func (d authDriver) CheckSigninRequest(req sdk.AuthConsumerSigninRequest) error {
if code, ok := req["code"]; !ok || code == "" {
return sdk.NewErrorFrom(sdk.ErrWrongRequest, "missing or invalid code")
}
return nil
}

func (d authDriver) CheckSigninStateToken(req sdk.AuthConsumerSigninRequest) error {
// Check if state is given and if its valid
state, okState := req["state"]
if !okState {
return sdk.NewErrorFrom(sdk.ErrWrongRequest, "missing state value")
}

return authentication.CheckDefaultSigninStateToken(state)
}

func (d authDriver) GetUserInfo(ctx context.Context, req sdk.AuthConsumerSigninRequest) (sdk.AuthDriverUserInfo, error) {
var info sdk.AuthDriverUserInfo

ctx2 := context.WithValue(context.Background(), oauth2.HTTPClient, http.DefaultClient)
oauth2Token, err := d.OAuth2Config.Exchange(ctx2, req["code"])
if err != nil {
return info, sdk.WrapError(err, "Failed to exchange token")
}
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
return info, sdk.WithStack(fmt.Errorf("no id_token field in oauth2 token"))
}
idToken, err := d.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return info, sdk.WrapError(err, "Failed to verify ID Token")
}
tokenClaim := make(map[string]interface{})
if err := idToken.Claims(&tokenClaim); err != nil {
return info, sdk.WrapError(err, "Cannot unmarshal OIDC claim")
}

// Check if email is verified.
// See standard claims at https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
if verified, ok := tokenClaim["email_verified"].(bool); !ok || !verified {
return info, sdk.NewErrorFrom(sdk.ErrInvalidUser, "OIDC user's email not verified")
}
if info.ExternalID, ok = tokenClaim["sub"].(string); !ok {
return info, sdk.WithStack(errors.New("missing OIDC user ID in token claim"))
}

if info.Username, ok = tokenClaim["preferred_username"].(string); !ok {
return info, sdk.WithStack(errors.New("Missing username in OIDC token claim"))
phsym marked this conversation as resolved.
Show resolved Hide resolved
}

info.Fullname, _ = tokenClaim["name"].(string)
if info.Email, ok = tokenClaim["email"].(string); !ok {
return info, sdk.WithStack(errors.New("Missing user's email in OIDC token claim"))
phsym marked this conversation as resolved.
Show resolved Hide resolved
}

return info, nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/chzyer/logex v1.1.10 // indirect
github.com/chzyer/readline v0.0.0-20171208011716-f6d7a1f6fbf3
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 // indirect
github.com/coreos/go-oidc v2.2.1+incompatible
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/distribution v2.7.0-rc.0+incompatible // indirect
github.com/docker/docker v1.13.1
Expand Down Expand Up @@ -106,6 +107,7 @@ require (
github.com/pkg/browser v0.0.0-20170505125900-c90ca0c84f15
github.com/pkg/errors v0.8.1
github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1 // indirect
github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f // indirect
github.com/prometheus/client_golang v1.1.0 // indirect
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 // indirect
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.13+incompatible h1:8F3hqu9fGYLBifCmRCJsicFqDx/D68Rt3q1JMazcgBQ=
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
github.com/coreos/go-semver v0.2.0 h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8=
Expand Down Expand Up @@ -397,6 +399,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1 h1:oL4IBbcqwhhNWh31bjOX8C/OCy0zs9906d/VUru+bqg=
github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU=
github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f h1:JDEmUDtyiLMyMlFwiaDOv2hxUp35497fkwePcLeV7j4=
github.com/pquerna/cachecontrol v0.0.0-20200819021114-67c6ae64274f/go.mod h1:hoLfEwdY11HjRfKFH6KqnPsfxlo3BP6bJehpDv8t6sQ=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
Expand Down
3 changes: 2 additions & 1 deletion sdk/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ const (
ConsumerCorporateSSO AuthConsumerType = "corporate-sso"
ConsumerGithub AuthConsumerType = "github"
ConsumerGitlab AuthConsumerType = "gitlab"
ConsumerOIDC AuthConsumerType = "openid-connect"
ConsumerTest AuthConsumerType = "futurama"
ConsumerTest2 AuthConsumerType = "planet-express"
)
Expand All @@ -290,7 +291,7 @@ func (t AuthConsumerType) IsValid() bool {
// IsValidExternal returns validity of given auth consumer type.
func (t AuthConsumerType) IsValidExternal() bool {
switch t {
case ConsumerLDAP, ConsumerCorporateSSO, ConsumerGithub, ConsumerGitlab, ConsumerTest, ConsumerTest2:
case ConsumerLDAP, ConsumerCorporateSSO, ConsumerGithub, ConsumerGitlab, ConsumerOIDC, ConsumerTest, ConsumerTest2:
return true
}
return false
Expand Down
15 changes: 14 additions & 1 deletion ui/src/app/views/auth/signin/signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,20 @@ export class SigninComponent implements OnInit {
.filter(d => d.type !== 'local' && d.type !== 'ldap' && d.type !== 'builtin')
.sort((a, b) => a.type < b.type ? -1 : 1)
.map(d => {
d.icon = d.type === 'corporate-sso' ? 'shield alternate' : d.type;
switch (d.type) {
case 'corporate-sso': {
d.icon = 'shield alternate';
break;
}
case 'openid-connect': {
d.icon = 'openid';
break;
}
default: {
d.icon = d.type;
break;
}
}
return d;
});

Expand Down
3 changes: 3 additions & 0 deletions ui/src/app/views/settings/user/edit/user.edit.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ export class UserEditComponent implements OnInit {
case 'corporate-sso':
icon['class'] = ['shield', 'alternate', 'icon'];
break;
case 'openid-connect':
icon['class'] = ['openid', 'icon'];
break;
default:
icon['class'] = [consumer.type, 'icon'];
break;
Expand Down
8 changes: 8 additions & 0 deletions ui/src/app/views/settings/user/edit/user.edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ <h2 class="ui header">
Corporate SSO
</div>
</ng-container>
<ng-container *ngSwitchCase="'openid-connect'">
<div class="center aligned header">
<i class="ui openid icon huge"></i>
</div>
<div class="center aligned description">
OpenID Connect
</div>
</ng-container>
<ng-container *ngSwitchDefault>
<div class="center aligned header">
<i class="ui {{d.type}} icon huge"></i>
Expand Down