Skip to content

Add basic OIDC client #15267

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

Merged
merged 1 commit into from
Dec 16, 2022
Merged

Add basic OIDC client #15267

merged 1 commit into from
Dec 16, 2022

Conversation

AlexTugarev
Copy link
Member

@AlexTugarev AlexTugarev commented Dec 9, 2022

This PR adds a base for OIDC clients.

What it does:

  • adds /iam/oidc/start and /iam/oidc/callback handlers for OIDC auth flows.
  • echos the obtained id_token when the OIDC auth was successful (req: Gitpod's Google Workspace account)

What's next:

Related Issue(s)

Part of #14955 (OIDC client implementation)
Relates to SSO Epic #7761

How to test

There is another test deployment with a flying configuration which can be used to check this.

Going to https://at-oidc-proxy.preview.gitpod-dev.com/iam/oidc/start will initiate the OIDC flow, which will only allow to proceed if you have a Gitpod account on Google. The result of the flow is less exciting should be an output of the id_token of yours.

Release Notes

NONE

Documentation

Werft options:

  • /werft with-local-preview
    If enabled this will build install/preview
  • /werft with-preview
  • /werft with-large-vm
  • /werft with-integration-tests=all
    Valid options are all, workspace, webapp, ide, jetbrains, vscode, ssh

Copy link
Member

@easyCZ easyCZ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIce. Left a couple of initial comments.

For all of this, unit tests are a strict must. Let's make sure we have good coverage here.

Additionally, the PR is quite large. It would benefit you, and other reviewers to make this more incremental (yes, there are seperate commits but ideally we roll out this changes incrementally, validating them incrementally in a rolled out env). You could split it into the following:

  • Config for the server seperately
  • Each handler separately (with unit tests)

There will be a couple of improvements we'll want to add to make it easier for us to work with HTTP. For example, we'll want a helper HandlerWrapper which takes (Request, Response) but returns an (error) such that we can make handling sub-errors a bit easier, and bit more coherent.

@easyCZ
Copy link
Member

easyCZ commented Dec 9, 2022

Read client configs from DB (depends on #15150)

We should think very hard about having cross-component dependencies on the db-level. Either we should be authartive about creating the Config on the IAM component (and having the DB implementation there), or IAM should do a ListClientConfig against Public API.

I believe IAM should be authoritative and should ultimately do the create/delete etc. Public API would then only delegate to this component.

@easyCZ
Copy link
Member

easyCZ commented Dec 9, 2022

For the tests, I recommend looking at httptest package. It allows you to basically bind a test server and then you can invoke it with sample requests, to run your handlers.

@AlexTugarev AlexTugarev marked this pull request as ready for review December 16, 2022 12:46
@easyCZ
Copy link
Member

easyCZ commented Dec 16, 2022

Ah, it auto-landed because the hold got removed..

@roboquat roboquat added the deployed: webapp Meta team change is running in production label Dec 19, 2022
Comment on lines +22 to +67
func OAuth2Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
log.Trace("at oauth2 middleware")
ctx := r.Context()
config, ok := ctx.Value(keyOIDCClientConfig{}).(OIDCClientConfig)
if !ok {
http.Error(rw, "config not found", http.StatusInternalServerError)
return
}

// http-only cookie written during flow start request
stateCookie, err := r.Cookie(stateCookieName)
if err != nil {
http.Error(rw, "state cookie not found", http.StatusBadRequest)
return
}
// the starte param passed back from IdP
stateParam := r.URL.Query().Get("state")
if stateParam == "" {
http.Error(rw, "state param not found", http.StatusBadRequest)
return
}
// on mismatch, obviously there is a client side error
if stateParam != stateCookie.Value {
http.Error(rw, "state did not match", http.StatusBadRequest)
return
}

code := r.URL.Query().Get("code")
if code == "" {
http.Error(rw, "code param not found", http.StatusBadRequest)
return
}

oauth2Token, err := config.OAuth2Config.Exchange(ctx, code)
if err != nil {
http.Error(rw, "failed to exchange token: "+err.Error(), http.StatusInternalServerError)
return
}

ctx = context.WithValue(ctx, keyOAuth2Result{}, OAuth2Result{
OAuth2Token: oauth2Token,
})
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd strongly recommend not to over-use middlewares which extract concrete data. Here's why:

  • Golang's context is not the same as the express.Request context. The context in go is used for downstream calls irrespective of whether it's an http handler or not. Using the context to pass request specific arguments results in carry extra baggage.
  • The middleware forces you write/and parse the data out of the context. Because context is not type safe, you incur extra overhead in extracting the data, but also risk cases where the context does not parse correctly.
  • It forces you to handle the existence of the data twice, once on the middleware here, and another when you pull the value from the context
  • Middlewares tend to make sense, once you have general purpose logic you want to re-use across handlers. Here, we only have a single handler. This causes a couple of problems:
    • Testing the handler becomes harder, you always have to test it together with the middleware
    • Readability becomes harder, the reader now has to step a layer above and figure out what's populating the context
  • It makes handling errors from context values much harder. The context of why the value is not present is missing. Yes, the middleware should return an error but we need to be defensive and also guard this in the handler.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

middlewares which extract concrete data

The point in using middleware here is to be made clear: this is to separate protocol implementations. First there is the OAuth2 layer, then the OIDC AuthN validation. It seems to be an acceptable cost if you consider that the OAuth2 layer can be reused for Git Auth later with a different handler.

The point on configuration and lax contract, indeed that's key-value onion might be fragile and requires complete coverage and precondition checks. I'll make improvements to get the coverage needed.

Golang's context is not the same as the express.Request context.

Do you have examples, or docs for that? I read different and saw different examples, see https://github.com/go-chi/chi and comparable, they advertising it as such. Could you name concrete problems with that?

I see the usage of middleware is justified here, especially for the separation of two protocols, where one is an extension to the other. The outcome of the first part can/must be checked and verified. To test the first part separately, one would need to slice it anyways. Same applies for the testability of the second part.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, checking for a solid connection should be a must here, thus it's worth revisiting tests. 👍🏻

var claims map[string]interface{}
err = result.IDToken.Claims(&claims)
if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a user error, no? If the user supplies the wrong claim, we shouldn't return a Server error.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claims is what Gitpod (as the OIDC client) can obtain from the end user.

Not being able to parse the id_token can have these causes:

  • unparseable gibberish received from IdP (e.g. their server/lb is misconfigured)
  • otherwise parsing failures => status code 500 it's Gitpod's fault

Also, while the error modes are still relevant, following PRs will remove this section.

Comment on lines +102 to +117
issuerParam := r.URL.Query().Get("issuer")
if issuerParam == "" {
return nil, errors.New("issuer param not specified")
}
issuer, err := url.QueryUnescape(issuerParam)
if err != nil {
return nil, errors.New("bad issuer param")
}
log.WithField("issuer", issuer).Trace("at GetClientConfigFromRequest")

for _, value := range service.configsById {
if value.Issuer == issuer {
return value, nil
}
}
return nil, errors.New("failed to find OIDC config for request")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to return more specific errors, as these need to be used to directly respond. If the caller does not specify the right arguments, it's a user error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true. I'll add an issue for that.

We need to identify the relevant error modes, to get a good signal (without noise) to be able to render something meaningful:

  • no params => 400
  • issuer (or maybe client id) + unmatched => 404
  • state (including client id) + unmatched => 404
  • client config matched + unauthorized => 401
    (that list is not complete)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
deployed: webapp Meta team change is running in production release-note-none size/XXL team: devx team: SID team: webapp Issue belongs to the WebApp team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants