Repository: openpubkey/openpubkey
Pull request #306: Adds ability to use custom OpenID Providers in Web Chooser
Author: login: Hanarion
Labels: name: enhancement
description: New feature or request
Created: 2025-03-28T21:29:12Z | Merged: 2025-03-31T11:59:32Z

Ref : https://github.com/openpubkey/opkssh/pull/60

This pull request includes significant changes to the `chooser` functionality to make it more dynamic and maintainable. The changes primarily focus on refactoring the code to support a more flexible way of handling multiple OpenID providers.

### Improvements to chooser functionality:

* [`client/choosers/chooser.tmpl`](diffhunk://#diff-e5a0ed895b3aa3a0d42161223e937b50faab148c81010dca2e5d785e49582502L22-R35): Refactored the template to dynamically generate provider buttons based on the `Providers` data structure, allowing for easier addition of new providers.

* [`client/choosers/web_chooser.go`](diffhunk://#diff-45d886d89330a9b6d96ac6301c031e0aeb53d864a5181e046eee574e93850cc1R101-R145): Added a `Provider` struct and modified the `ChooseOp` function to populate the provider data dynamically. This includes sorting provider names and handling both known and unknown providers more gracefully.

### Codebase simplification:

* [`client/choosers/web_chooser.go`](diffhunk://#diff-45d886d89330a9b6d96ac6301c031e0aeb53d864a5181e046eee574e93850cc1L204-R238): Simplified the `IssuerToName` function to handle unknown issuers more gracefully by returning the issuer domain name if it starts with "https://".

### Dependency management:

* [`client/choosers/web_chooser.go`](diffhunk://#diff-45d886d89330a9b6d96ac6301c031e0aeb53d864a5181e046eee574e93850cc1R29): Added the `sort` package to handle sorting of provider names.


Diff:
diff --git a/client/choosers/chooser.tmpl b/client/choosers/chooser.tmpl
index 387c9dbe..5466ce3e 100644
--- a/client/choosers/chooser.tmpl
+++ b/client/choosers/chooser.tmpl
@@ -19,17 +19,20 @@
         </div>
         <br>
         <div style="background-color: grey; padding: 20px; display: inline-block;">
-            <div style="display:{{.Google}};">
-                <a href="/select?op=google"><img src="static/buttons/google-light.svg" alt="Sign in with Google" style="width:200px;" /></a>
+        {{range .Providers}}
+            <div>
+                <a href="/select?op={{.Name}}">
+                {{if .Button}}
+                    <img src="static/buttons/{{.Button}}" alt="Sign in with {{.Name}}" style="width:200px;" />
+                {{else}}
+                <button style="width:200px; height:50px; background-color: #4CAF50; color: white; border: none; cursor: pointer;">
+                    Sign in with {{.Name}}
+                </button>
+                {{end}}
+                </a>
             </div>
             <br>
-            <div style="visibility:{{.Azure}};">
-                <a href="/select?op=azure"><img src="static/buttons/azure-dark.svg" alt="Sign in with Azure" style="width:200px;" /></a>
-            </div>
-            <br>
-            <div style="visibility:{{.Gitlab}};">
-                <a href="/select?op=gitlab"><img src="static/buttons/gitlab-dark.svg" alt="Sign in with Gitlab" style="width:200px;" /></a>
-            </div>
+        {{end}}
         </div>
 
     </body>
diff --git a/client/choosers/web_chooser.go b/client/choosers/web_chooser.go
index 40b0a255..aee78547 100644
--- a/client/choosers/web_chooser.go
+++ b/client/choosers/web_chooser.go
@@ -26,6 +26,7 @@ import (
 	"net"
 	"net/http"
 	"net/http/httptest"
+	"sort"
 	"strings"
 
 	"github.com/openpubkey/openpubkey/providers"
@@ -97,24 +98,51 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 	}
 
 	mux.HandleFunc("/chooser", func(w http.ResponseWriter, r *http.Request) {
-		data := struct {
-			Google string
-			Azure  string
-			Gitlab string
-		}{
-			Google: "none",
-			Azure:  "none",
-			Gitlab: "none",
-		}
-		if _, ok := providerMap["google"]; ok {
-			data.Google = "block"
+		type Provider struct {
+			Name   string
+			Button string
 		}
-		if _, ok := providerMap["azure"]; ok {
-			data.Azure = "block"
+
+		data := struct {
+			Providers []Provider
+		}{}
+
+		sortedProviderNames := make([]string, 0)
+		for providerName := range providerMap {
+			sortedProviderNames = append(sortedProviderNames, providerName)
 		}
-		if _, ok := providerMap["gitlab"]; ok {
-			data.Gitlab = "block"
+		// Sort the provider names
+		sort.Strings(sortedProviderNames)
+
+		for _, providerName := range sortedProviderNames {
+			if providerName == "google" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "google-light.svg",
+				})
+				continue
+			}
+			if providerName == "azure" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "azure-dark.svg",
+				})
+				continue
+			}
+			if providerName == "gitlab" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "gitlab-light.svg",
+				})
+				continue
+			}
+			data.Providers = append(data.Providers, Provider{
+				Name:   providerName,
+				Button: "",
+			})
+
 		}
+
 		w.Header().Set("Content-Type", "text/html")
 		if err := chooserTemplate.Execute(w, data); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -201,6 +229,12 @@ func IssuerToName(issuer string) (string, error) {
 	case strings.HasPrefix(issuer, "https://gitlab.com"):
 		return "gitlab", nil
 	default:
-		return "", fmt.Errorf("unknown OpenID Provider issuer: %s", issuer)
+		if strings.HasPrefix(issuer, "https://") {
+			// Returns issuer without the "https://" prefix and without any path remaining on the url
+			// e.g. https://accounts.google.com/fdsfa/fdsafsad -> accounts.google.com
+			return strings.Split(strings.TrimPrefix(issuer, "https://"), "/")[0], nil
+
+		}
+		return "", fmt.Errorf("invalid OpenID Provider issuer: %s", issuer)
 	}
 }
diff --git a/client/choosers/web_chooser_test.go b/client/choosers/web_chooser_test.go
index 89666e30..67648cb1 100644
--- a/client/choosers/web_chooser_test.go
+++ b/client/choosers/web_chooser_test.go
@@ -147,21 +147,6 @@ func TestDuplicateProviderError(t *testing.T) {
 	require.Nil(t, op)
 }
 
-func TestUnknownProviderError(t *testing.T) {
-	googleOpOptions := providers.GetDefaultGoogleOpOptions()
-	googleOpOptions.Issuer = "https://unknown-issuer.example.com"
-	googleOp := providers.NewGoogleOpWithOptions(googleOpOptions)
-
-	webChooser := WebChooser{
-		OpList:        []providers.BrowserOpenIdProvider{googleOp},
-		OpenBrowser:   false,
-		useMockServer: true,
-	}
-	op, err := webChooser.ChooseOp(context.Background())
-	require.ErrorContains(t, err, "unknown OpenID Provider issuer")
-	require.Nil(t, op)
-}
-
 func TestIssuerToName(t *testing.T) {
 	name, err := IssuerToName("https://accounts.google.com")
 	require.NoError(t, err)
@@ -175,7 +160,11 @@ func TestIssuerToName(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, "gitlab", name)
 
-	name, err = IssuerToName("https://error.example.com")
-	require.ErrorContains(t, err, "unknown OpenID Provider")
+	name, err = IssuerToName("https://noterror.example.com")
+	require.NoError(t, err)
+	require.Equal(t, "noterror.example.com", name)
+
+	name, err = IssuerToName("error.example.com")
+	require.ErrorContains(t, err, "invalid OpenID Provider issuer: error.example.com")
 	require.Equal(t, "", name)
 }
diff --git a/providers/standard_provider.go b/providers/standard_provider.go
index 3a258f3c..55f151b2 100644
--- a/providers/standard_provider.go
+++ b/providers/standard_provider.go
@@ -158,7 +158,7 @@ func (s *StandardOp) requestTokens(ctx context.Context, cicHash string) (*simple
 	if s.reuseBrowserWindowHook != nil {
 		s.reuseBrowserWindowHook <- loginURI
 	} else if s.OpenBrowser {
-		logrus.Infof("Opening browser to on http://%s/", loginURI)
+		logrus.Infof("Opening browser to on %s/", loginURI)
 		if err := util.OpenUrl(loginURI); err != nil {
 			logrus.Errorf("Failed to open url: %v", err)
 		}


Reviews:
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T19:26:04Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T19:26:26Z
user: login: Hanarion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T19:30:59Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T19:57:17Z
user: login: Hanarion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-29T20:52:03Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-29T21:10:04Z
user: login: Hanarion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T09:26:48Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:47:15Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:47:48Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:48:33Z
user: login: EthanHeilman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T11:49:23Z
user: login: EthanHeilman
body: LGTM, I'm going to do a manual test of this later today using your opkssh custom provider PR. Thanks for the work on this
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-31T11:58:32Z

Review comments:
diff_hunk: @@ -201,6 +229,12 @@ func IssuerToName(issuer string) (string, error) {
 	case strings.HasPrefix(issuer, "https://gitlab.com"):
 		return "gitlab", nil
 	default:
-		return "", fmt.Errorf("unknown OpenID Provider issuer: %s", issuer)
path: client/choosers/web_chooser.go
user: login: EthanHeilman
body: I believe the test is failing because the test is expecting to get this error back immediately but after these changes there is no such thing as an unknown provider so it completes successfully

```
func TestUnknownProviderError(t *testing.T) {
	googleOpOptions := providers.GetDefaultGoogleOpOptions()
	googleOpOptions.Issuer = "https://unknown-issuer.example.com"
	googleOp := providers.NewGoogleOpWithOptions(googleOpOptions)
```

https://github.com/openpubkey/openpubkey/blob/08989a372e547aa4499cb15f30c82ad61f02623e/client/choosers/web_chooser_test.go#L150
created_at: 2025-03-29T19:26:04Z
author_association: MEMBER
diff_hunk: @@ -97,24 +98,51 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 	}
 
 	mux.HandleFunc("/chooser", func(w http.ResponseWriter, r *http.Request) {
-		data := struct {
-			Google string
-			Azure  string
-			Gitlab string
-		}{
-			Google: "none",
-			Azure:  "none",
-			Gitlab: "none",
-		}
-		if _, ok := providerMap["google"]; ok {
-			data.Google = "block"
+		type Provider struct {
+			Name   string
+			Button string
 		}
-		if _, ok := providerMap["azure"]; ok {
-			data.Azure = "block"
+
+		data := struct {
+			Providers []Provider
+		}{}
+
+		sortedProviderNames := make([]string, 0)
+		for providerName := range providerMap {
+			sortedProviderNames = append(sortedProviderNames, providerName)
 		}
-		if _, ok := providerMap["gitlab"]; ok {
-			data.Gitlab = "block"
+		// Sort the provider names
+		sort.Strings(sortedProviderNames)
+
+		for _, providerName := range sortedProviderNames {
+			if providerName == "google" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "google-light.svg",
+				})
+				continue
+			}
+			if providerName == "azure" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "azure-dark.svg",
+				})
+				continue
+			}
+			if providerName == "gitlab" {
+				data.Providers = append(data.Providers, Provider{
+					Name:   providerName,
+					Button: "gitlab-light.svg",
+				})
+				continue
+			}
+			data.Providers = append(data.Providers, Provider{
+				Name:   providerName,
+				Button: "",
+			})
+
 		}
+
path: client/choosers/web_chooser.go
user: login: EthanHeilman
body: ```suggestion
```
created_at: 2025-03-29T19:26:25Z
author_association: MEMBER
diff_hunk: @@ -201,6 +229,12 @@ func IssuerToName(issuer string) (string, error) {
 	case strings.HasPrefix(issuer, "https://gitlab.com"):
 		return "gitlab", nil
 	default:
-		return "", fmt.Errorf("unknown OpenID Provider issuer: %s", issuer)
path: client/choosers/web_chooser.go
user: login: Hanarion
body: Yeah i figured out that i didn't changed the tests after writing this comment
I just fixed the tests and i also added a HTTP check for the code 200 on the openid endpoint, as now the user can enter everything, better to check if he didn't make any mistakes before showing him the chooser and failing there.

created_at: 2025-03-29T19:30:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,13 +73,17 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 
 	providerMap := map[string]providers.BrowserOpenIdProvider{}
 	for _, provider := range wc.OpList {
-		if providerName, err := IssuerToName(provider.Issuer()); err != nil {
-			return nil, err
-		} else {
-			if _, ok := providerMap[providerName]; ok {
-				return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer())
+		if providers.CheckURLStatus(provider.Issuer() + "/.well-known/openid-configuration") {
path: client/choosers/web_chooser.go
user: login: EthanHeilman
body: We try to hide these details so that we can mock them in tests and the unittests don't depend on the external resources. For instance if example.com added the page `https://unknown-issuer.example.com/.well-known/openid-configuration` our tests would break.

I don't think we have to cover the unknown provider testcase anymore.
created_at: 2025-03-29T19:57:16Z
author_association: MEMBER
diff_hunk: @@ -72,13 +73,17 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 
 	providerMap := map[string]providers.BrowserOpenIdProvider{}
 	for _, provider := range wc.OpList {
-		if providerName, err := IssuerToName(provider.Issuer()); err != nil {
-			return nil, err
-		} else {
-			if _, ok := providerMap[providerName]; ok {
-				return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer())
+		if providers.CheckURLStatus(provider.Issuer() + "/.well-known/openid-configuration") {
path: client/choosers/web_chooser.go
user: login: Hanarion
body: I think it's better to keep the check, but to remove the test then.
I the user enters a bad value, it's better to inform him explicitly that his provider is wrong

Are you agreeing with that ?
created_at: 2025-03-29T20:52:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -72,13 +73,17 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 
 	providerMap := map[string]providers.BrowserOpenIdProvider{}
 	for _, provider := range wc.OpList {
-		if providerName, err := IssuerToName(provider.Issuer()); err != nil {
-			return nil, err
-		} else {
-			if _, ok := providerMap[providerName]; ok {
-				return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer())
+		if providers.CheckURLStatus(provider.Issuer() + "/.well-known/openid-configuration") {
path: client/choosers/web_chooser.go
user: login: EthanHeilman
body: If you really want this check add a method to the providers interface like `TestReachable() error`. Then implement it for the standard provider to hit the well-known URI. That way people writing tests can mock the response however they want because the details are hidden inside the interface.

https://github.com/openpubkey/openpubkey/blob/08989a372e547aa4499cb15f30c82ad61f02623e/providers/op.go#L32-L39

The most likely way this error will get hit is when an OpenID Provider is offline or the persons computer is offline. For instance open their laptop in a hotel and haven't auth'd the WIFI yet. The error message `provider issuer is not a valid openid issuer: ` is going to be very confusing them because the real problem is just that their laptop can't connect to the OpenID Provider but the configuration is actually correct.

Currently we don't assume any of the values about a provider as they are hidden behind the interface. In theory we could support a provider that doesn't have an standard `/.well-known/openid-configuration`.



created_at: 2025-03-29T21:10:03Z
author_association: MEMBER
diff_hunk: @@ -72,13 +73,17 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 
 	providerMap := map[string]providers.BrowserOpenIdProvider{}
 	for _, provider := range wc.OpList {
-		if providerName, err := IssuerToName(provider.Issuer()); err != nil {
-			return nil, err
-		} else {
-			if _, ok := providerMap[providerName]; ok {
-				return nil, fmt.Errorf("provider in web chooser found with duplicate issuer: %s", provider.Issuer())
+		if providers.CheckURLStatus(provider.Issuer() + "/.well-known/openid-configuration") {
path: client/choosers/web_chooser.go
user: login: Hanarion
body: After thinking, i don't think this check is really useful after all, as the error that will happen if there is no check is more explicit than the check message :
`Error logging in: error requesting OIDC tokens from OpenID Provider: error creating provider: Get "https://custom-idp1.tld/application/o/opkssh/.well-known/openid-configuration": dial tcp: lookup custom-idp1.tld: no such host`


created_at: 2025-03-31T09:26:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,12 +19,11 @@ package providers
 import (
 	"crypto/rand"
 	"fmt"
+	httphelper "github.com/zitadel/oidc/v3/pkg/http"
path: providers/utils.go
user: login: EthanHeilman
body: ```suggestion
```
created_at: 2025-03-31T11:47:15Z
author_association: MEMBER
diff_hunk: @@ -19,12 +19,11 @@ package providers
 import (
 	"crypto/rand"
 	"fmt"
+	httphelper "github.com/zitadel/oidc/v3/pkg/http"
 	"io"
 	"net"
 	"net/url"
 	"strings"
path: providers/utils.go
user: login: EthanHeilman
body: ```suggestion
	"strings"

	httphelper "github.com/zitadel/oidc/v3/pkg/http"
```
created_at: 2025-03-31T11:47:47Z
author_association: MEMBER
diff_hunk: @@ -19,12 +19,11 @@ package providers
 import (
 	"crypto/rand"
 	"fmt"
+	httphelper "github.com/zitadel/oidc/v3/pkg/http"
 	"io"
 	"net"
 	"net/url"
 	"strings"
path: providers/utils.go
user: login: EthanHeilman
body: Just keeping git diff clean
created_at: 2025-03-31T11:48:33Z
author_association: MEMBER
diff_hunk: @@ -23,7 +23,7 @@ import (
 	"net"
 	"net/url"
 	"strings"
-
+	
path: providers/utils.go
user: login: EthanHeilman
body: ```suggestion

```
created_at: 2025-03-31T11:49:23Z
author_association: MEMBER

PR comments:
user: login: Hanarion
created_at: 2025-03-28T21:31:21Z
body: Relates to this issue : https://github.com/openpubkey/openpubkey/issues/303
author_association: CONTRIBUTOR
user: login: Hanarion
created_at: 2025-03-28T21:32:42Z
body: Using opkssh with code from the pull request https://github.com/openpubkey/opkssh/pull/60:
![image](https://github.com/user-attachments/assets/a12eff98-ef0e-4e2c-8ab3-eab0f2272e65)

author_association: CONTRIBUTOR
user: login: EthanHeilman
created_at: 2025-03-29T18:43:31Z
body: This is really cool and something I've personally wanted in OpenPubkey since I created the web chooser. I need to eat some food, but when I come back I'll review the code.

Will https://github.com/openpubkey/opkssh/pull/60 need this change? I ask because it is does, we should merge this first and then do a OpenPubkey release.


author_association: MEMBER
user: login: Hanarion
created_at: 2025-03-29T19:13:38Z
body: > Will [openpubkey/opkssh#60](https://github.com/openpubkey/opkssh/pull/60) need this change? I ask because it is does, we should merge this first and then do a OpenPubkey release.

For the code that is commited for now, it does not need to, but in the future, if we implement the OPKSSH_PROVIDERS mechanism, then yes it will be needed in order to show all the providers configured on opkssh in the web chooser

But as you saw, there is an issue with the tests and can't figure out why, i thought it was a problem on my end, but the same seems to happen for the CI/CD, the TestUnknownProviderError seems to hang indefinitively. I need to dig down what's going on but from what i seem to see, maybe it's a timeout issue on a unresponsive provider (if provider down or random valid dns without web server) 


author_association: CONTRIBUTOR
user: login: EthanHeilman
created_at: 2025-03-30T00:39:12Z
body: @Hanarion I'm going to working on this all Sunday, I'm going to try to merge this by Sunday.
author_association: MEMBER
user: login: EthanHeilman
created_at: 2025-03-31T11:55:03Z
body: Made some minor fixup to solve linter issues (one of which I caused)
author_association: MEMBER
user: login: EthanHeilman
created_at: 2025-03-31T16:51:02Z
body: @Hanarion I advertised your work on bsky. I didn't know if you had a bsky account. If you do I'll link to it https://bsky.app/profile/ethanheilman.bsky.social/post/3llost35vm22g
author_association: MEMBER