Skip to content

Commit

Permalink
feat(api): basic support for openid-connect authentication provider (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
phsym authored Aug 27, 2020
1 parent 5bfdc7c commit 7288e75
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 3 deletions.
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"))
}

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"))
}

return info, nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 @@ -101,6 +102,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 @@ -72,6 +72,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 @@ -402,6 +404,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

0 comments on commit 7288e75

Please sign in to comment.