Repository: openpubkey/openpubkey (go)

Issue:
Add Gitlab user OIDC OP 
Currently we support the gitlab OP for issuing ID Tokens to workflows in gitlab-ci but not the gitlab OP for issuing ID Tokens to users (humans). There is interest in using OpenPubkey in gitlab workspaces: https://gitlab.com/gitlab-org/gitlab/-/issues/438790
This requires support the user auth for the gitlab OP. This should not be difficult to do.



Pull request description:
Adds Gitlab Provider to OPKSSH
Closes #199 

- Adds a new Gitlab provider for users using opkssh

Fixes https://github.com/openpubkey/openpubkey/issues/274

Patch:
diff --git a/client/choosers/chooser.tmpl b/client/choosers/chooser.tmpl
index cd6713d..387c9db 100644
--- a/client/choosers/chooser.tmpl
+++ b/client/choosers/chooser.tmpl
@@ -26,6 +26,10 @@
             <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>
         </div>
 
     </body>
diff --git a/client/choosers/static/buttons/gitlab-dark.svg b/client/choosers/static/buttons/gitlab-dark.svg
new file mode 100644
index 0000000..10e5229
--- /dev/null
+++ b/client/choosers/static/buttons/gitlab-dark.svg
@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="215" height="41" viewBox="0 0 215 41">
+  <title>GitLab-SignIn</title>
+  <rect width="215" height="41" fill="#333" stroke="#8c8c8c" stroke-width="1"/>
+  
+  <!-- GitLab Logo -->
+  <g transform="translate(13, 2) scale(0.1)">
+    <defs>
+      <style>
+        .st0 { fill: #fca326; }
+        .st1 { fill: #fc6d26; }
+        .st2 { fill: #e24329; }
+      </style>
+    </defs>
+    <g>
+      <path class="st2" d="M265.3,174.4l-.2-.6-21.2-55.3c-.4-1.1-1.2-2-2.2-2.6s-2.1-.9-3.3-.9-2.3.5-3.2,1.2c-.9.7-1.6,1.7-1.9,2.9l-14.3,43.8h-57.9l-14.3-43.8c-.3-1.1-1-2.1-1.9-2.9-.9-.7-2-1.2-3.2-1.2s-2.3.2-3.3.9c-1,.6-1.8,1.5-2.2,2.6l-21.2,55.3-.2.6c-6.3,16.4-.9,34.9,13.1,45.5h.2c0,.1,32.3,24.3,32.3,24.3l16,12.1,9.7,7.3c2.3,1.8,5.6,1.8,7.9,0l9.7-7.3,16-12.1,32.5-24.3h0c14-10.6,19.3-29.1,13-45.5h.1Z"/>
+      <path class="st1" d="M265.3,174.4l-.2-.6c-10.5,2.2-20.2,6.6-28.5,12.8-.1,0-25.2,19.1-46.6,35.2,15.8,12,29.6,22.4,29.6,22.4l32.5-24.3h0c14-10.6,19.3-29.1,13-45.5h.2Z"/>
+      <path class="st0" d="M160.3,244.2l16,12.1,9.7,7.3c2.3,1.8,5.6,1.8,7.9,0l9.7-7.3,16-12.1s-13.8-10.4-29.6-22.4c-15.9,12-29.7,22.4-29.7,22.4h0Z"/>
+      <path class="st1" d="M143.4,186.6c-8.3-6.2-18-10.7-28.5-12.8l-.2.6c-6.3,16.4-.9,34.9,13.1,45.5h.2c0,.1,32.3,24.3,32.3,24.3,0,0,13.8-10.4,29.7-22.4-21.3-16.1-46.4-35.1-46.6-35.2h0Z"/>
+    </g>
+  </g>
+  
+  <!-- Text: Sign in with GitLab -->
+  <text x="50" y="26" font-family="Arial, sans-serif" font-size="14" fill="#ffffff">Sign in with GitLab</text>
+</svg>
diff --git a/client/choosers/static/buttons/gitlab-light.svg b/client/choosers/static/buttons/gitlab-light.svg
new file mode 100644
index 0000000..3b08cbf
--- /dev/null
+++ b/client/choosers/static/buttons/gitlab-light.svg
@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="215" height="41" viewBox="0 0 215 41">
+  <title>GitLab-SignIn</title>
+  <rect width="215" height="41" fill="#fff" stroke="#8c8c8c" stroke-width="1"/>
+  
+  <!-- GitLab Logo -->
+  <g transform="translate(13, 2) scale(0.1)">
+    <defs>
+      <style>
+        .st0 { fill: #fca326; }
+        .st1 { fill: #fc6d26; }
+        .st2 { fill: #e24329; }
+      </style>
+    </defs>
+    <g>
+      <path class="st2" d="M265.3,174.4l-.2-.6-21.2-55.3c-.4-1.1-1.2-2-2.2-2.6s-2.1-.9-3.3-.9-2.3.5-3.2,1.2c-.9.7-1.6,1.7-1.9,2.9l-14.3,43.8h-57.9l-14.3-43.8c-.3-1.1-1-2.1-1.9-2.9-.9-.7-2-1.2-3.2-1.2s-2.3.2-3.3.9c-1,.6-1.8,1.5-2.2,2.6l-21.2,55.3-.2.6c-6.3,16.4-.9,34.9,13.1,45.5h.2c0,.1,32.3,24.3,32.3,24.3l16,12.1,9.7,7.3c2.3,1.8,5.6,1.8,7.9,0l9.7-7.3,16-12.1,32.5-24.3h0c14-10.6,19.3-29.1,13-45.5h.1Z"/>
+      <path class="st1" d="M265.3,174.4l-.2-.6c-10.5,2.2-20.2,6.6-28.5,12.8-.1,0-25.2,19.1-46.6,35.2,15.8,12,29.6,22.4,29.6,22.4l32.5-24.3h0c14-10.6,19.3-29.1,13-45.5h.2Z"/>
+      <path class="st0" d="M160.3,244.2l16,12.1,9.7,7.3c2.3,1.8,5.6,1.8,7.9,0l9.7-7.3,16-12.1s-13.8-10.4-29.6-22.4c-15.9,12-29.7,22.4-29.7,22.4h0Z"/>
+      <path class="st1" d="M143.4,186.6c-8.3-6.2-18-10.7-28.5-12.8l-.2.6c-6.3,16.4-.9,34.9,13.1,45.5h.2c0,.1,32.3,24.3,32.3,24.3,0,0,13.8-10.4,29.7-22.4-21.3-16.1-46.4-35.1-46.6-35.2h0Z"/>
+    </g>
+  </g>
+  
+  <!-- Text: Sign in with GitLab -->
+  <text x="50" y="26" font-family="Arial, sans-serif" font-size="14" fill="#5e5e5e">Sign in with GitLab</text>
+</svg>
diff --git a/client/choosers/web_chooser.go b/client/choosers/web_chooser.go
index bd9dc91..2878020 100644
--- a/client/choosers/web_chooser.go
+++ b/client/choosers/web_chooser.go
@@ -99,9 +99,11 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 		data := struct {
 			Google string
 			Azure  string
+			Gitlab string
 		}{
 			Google: "none",
 			Azure:  "none",
+			Gitlab: "none",
 		}
 		if _, ok := providerMap["google"]; ok {
 			data.Google = "block"
@@ -109,6 +111,9 @@ func (wc *WebChooser) ChooseOp(ctx context.Context) (providers.OpenIdProvider, e
 		if _, ok := providerMap["azure"]; ok {
 			data.Azure = "block"
 		}
+		if _, ok := providerMap["gitlab"]; ok {
+			data.Gitlab = "block"
+		}
 		w.Header().Set("Content-Type", "text/html")
 		if err := chooserTemplate.Execute(w, data); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -190,6 +195,8 @@ func IssuerToName(issuer string) (string, error) {
 		return "google", nil
 	case strings.HasPrefix(issuer, "https://login.microsoftonline.com"):
 		return "azure", nil
+	case strings.HasPrefix(issuer, "https://gitlab.com"):
+		return "gitlab", nil
 	default:
 		return "", fmt.Errorf("unknown OpenID Provider issuer: %s", issuer)
 	}
diff --git a/opkssh/commands/add.go b/opkssh/commands/add.go
index 4ed9a78..fe6a1d0 100644
--- a/opkssh/commands/add.go
+++ b/opkssh/commands/add.go
@@ -64,12 +64,44 @@ func (a *AddCmd) LoadPolicy() (*policy.Policy, string, error) {
 	return systemPolicy, policy.SystemDefaultPolicyPath, nil
 }
 
+// GetPolicyPath returns the path to the policy file that the current command
+// will write to.
+func (a *AddCmd) GetPolicyPath(principal string, userEmail string, issuer string) (string, error) {
+	// Try to read system policy first
+	_, err := a.PolicyFileLoader.LoadSystemDefaultPolicy()
+	if err != nil {
+		if errors.Is(err, os.ErrPermission) {
+			// If current process doesn't have permission, try reading the user
+			// policy file.
+			policyFilePath, err := a.PolicyFileLoader.UserPolicyPath(a.Username)
+			if err != nil {
+				return "", err
+			}
+			return policyFilePath, nil
+		} else {
+			// Non-permission error (e.g. system policy file missing or invalid
+			// permission bits set). Return error
+			return "", err
+		}
+	}
+	return policy.SystemDefaultPolicyPath, nil
+}
+
 // Add adds a new allowed principal to the user whose email is equal to
 // userEmail. The current policy file is read and modified.
 //
 // If successful, returns the policy filepath updated. Otherwise, returns a
 // non-nil error
 func (a *AddCmd) Add(principal string, userEmail string, issuer string) (string, error) {
+	policyPath, err := a.GetPolicyPath(principal, userEmail, issuer)
+	if err != nil {
+		return "", fmt.Errorf("failed to load current policy: %w", err)
+	}
+	err = a.PolicyFileLoader.CreateIfDoesNotExist(policyPath)
+	if err != nil {
+		return "", fmt.Errorf("failed to create policy file: %w", err)
+	}
+
 	// Read current policy
 	currentPolicy, policyFilePath, err := a.LoadPolicy()
 	if err != nil {
diff --git a/opkssh/commands/install.go b/opkssh/commands/install.go
index 4c2212c..f83b7bd 100644
--- a/opkssh/commands/install.go
+++ b/opkssh/commands/install.go
@@ -38,6 +38,7 @@ func Install() error {
 
 PROVIDER_GOOGLE="https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h"
 PROVIDER_MICROSOFT="https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h"
+PROVIDER_GITLAB="https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h"
 
 sudo mkdir -p /etc/opk
 sudo touch /etc/opk/auth_id
@@ -53,6 +54,7 @@ if [ -s /etc/opk/providers ]; then
 else
 	echo "$PROVIDER_GOOGLE" >> /etc/opk/providers
 	echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers
+	echo "$PROVIDER_GITLAB" >> /etc/opk/providers
 fi
 
 sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
diff --git a/opkssh/main.go b/opkssh/main.go
index 5eb2dfa..573f1e4 100644
--- a/opkssh/main.go
+++ b/opkssh/main.go
@@ -111,9 +111,13 @@ func run() int {
 			azureOpOptions.GQSign = false
 			azureOp := providers.NewAzureOpWithOptions(azureOpOptions)
 
+			gitlabOpOptions := providers.GetDefaultGitlabOpOptions()
+			gitlabOpOptions.GQSign = false
+			gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions)
+
 			var err error
 			provider, err = choosers.NewWebChooser(
-				[]providers.BrowserOpenIdProvider{googleOp, azureOp},
+				[]providers.BrowserOpenIdProvider{googleOp, azureOp, gitlabOp},
 			).ChooseOp(context.Background())
 			if err != nil {
 				log.Println("ERROR selecting op:", err)
@@ -140,7 +144,6 @@ func run() int {
 				return 1
 			}
 		}
-
 	case "verify":
 		// Setup logger
 		logFile, err := os.OpenFile("/var/log/openpubkey.log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0700)
@@ -226,6 +229,8 @@ func run() int {
 			inputIssuer = "https://accounts.google.com"
 		case "azure", "microsoft":
 			inputIssuer = "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"
+		case "gitlab":
+			inputIssuer = "https://gitlab.com"
 		}
 
 		// Execute add command
@@ -284,7 +289,7 @@ func checkOpenSSHVersion() {
 	cmd := exec.Command("sshd", "-V")
 	output, err := cmd.CombinedOutput()
 	if err != nil {
-		fmt.Println("Error executing ssh -V:", err)
+		fmt.Println("Error executing sshd -V:", err)
 		return
 	}
 
diff --git a/opkssh/policy/fileloader.go b/opkssh/policy/fileloader.go
index 0e2ed64..0870f3e 100644
--- a/opkssh/policy/fileloader.go
+++ b/opkssh/policy/fileloader.go
@@ -34,6 +34,24 @@ type FileLoader struct {
 	Fs afero.Fs
 }
 
+func (l FileLoader) CreateIfDoesNotExist(path string) error {
+	exists, err := afero.Exists(l.Fs, path)
+	if err != nil {
+		return err
+	}
+	if !exists {
+		file, err := l.Fs.Create(path)
+		if err != nil {
+			return fmt.Errorf("failed to create file: %w", err)
+		}
+		file.Close()
+		if err := l.Fs.Chmod(path, ModeOnlyOwner); err != nil {
+			return fmt.Errorf("failed to set file permissions: %w", err)
+		}
+	}
+	return nil
+}
+
 // LoadFileAtPath validates that the file at path exists, can be read
 // by the current process, and has the correct permission bits set. Parses the
 // contents and returns the bytes if file permissions are valid and
diff --git a/opkssh/policy/providerpolicy.go b/opkssh/policy/providerpolicy.go
index c0c49f7..358f38c 100644
--- a/opkssh/policy/providerpolicy.go
+++ b/opkssh/policy/providerpolicy.go
@@ -84,6 +84,11 @@ func (p *ProviderPolicy) CreateVerifier() (*verifier.Verifier, error) {
 			opts.Issuer = row.Issuer
 			opts.ClientID = row.ClientID
 			provider = providers.NewAzureOpWithOptions(opts)
+		} else if row.Issuer == "https://gitlab.com" {
+			opts := providers.GetDefaultGitlabOpOptions()
+			opts.Issuer = row.Issuer
+			opts.ClientID = row.ClientID
+			provider = providers.NewGitlabOpWithOptions(opts)
 		} else {
 			return nil, fmt.Errorf("unsupported issuer: %s", row.Issuer)
 		}
diff --git a/opkssh/policy/userfileloader.go b/opkssh/policy/userfileloader.go
index a2b302d..f2edf86 100644
--- a/opkssh/policy/userfileloader.go
+++ b/opkssh/policy/userfileloader.go
@@ -63,6 +63,10 @@ func NewUserFileLoader() *UserPolicyLoader {
 	}
 }
 
+func (l UserPolicyLoader) CreateIfDoesNotExist(path string) error {
+	return l.FileLoader.CreateIfDoesNotExist(path)
+}
+
 // LoadPolicyAtPath validates that the policy file at path exists, can be read
 // by the current process, and has the correct permission bits set. Parses the
 // contents and returns a policy.Policy if file permissions are valid and
@@ -90,6 +94,22 @@ func (l *UserPolicyLoader) LoadSystemDefaultPolicy() (*Policy, error) {
 	return policy, nil
 }
 
+// UserPolicyPath returns the path to the user's opkssh policy file at
+// ~/.opk/auth_id.
+func (l *UserPolicyLoader) UserPolicyPath(username string) (string, error) {
+	user, err := l.UserLookup.Lookup(username)
+	if err != nil {
+		return "", fmt.Errorf("failed to lookup username %s: %w", username, err)
+	}
+	userHomeDirectory := user.HomeDir
+	if userHomeDirectory == "" {
+		return "", fmt.Errorf("user %s does not have a home directory", username)
+	}
+
+	policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id")
+	return policyFilePath, nil
+}
+
 // LoadUserPolicy reads the user's opkssh policy at ~/.opk/auth_id (where ~
 // maps to username's home directory) and returns the filepath read. An error is
 // returned if the file cannot be read, if the permission bits are not correct,
@@ -99,16 +119,10 @@ func (l *UserPolicyLoader) LoadSystemDefaultPolicy() (*Policy, error) {
 // included in the returned policy. A user policy's entry is considered valid if
 // it gives username access. The returned policy is stripped of invalid entries.
 func (l *UserPolicyLoader) LoadUserPolicy(username string, skipInvalidEntries bool) (*Policy, string, error) {
-	user, err := l.UserLookup.Lookup(username)
+	policyFilePath, err := l.UserPolicyPath(username)
 	if err != nil {
-		return nil, "", fmt.Errorf("failed to lookup username %s: %w", username, err)
+		return nil, "", err
 	}
-	userHomeDirectory := user.HomeDir
-	if userHomeDirectory == "" {
-		return nil, "", fmt.Errorf("user %s does not have a home directory", username)
-	}
-
-	policyFilePath := path.Join(userHomeDirectory, ".opk", "auth_id")
 	policy, err := l.LoadPolicyAtPath(policyFilePath)
 	if err != nil {
 		return nil, "", fmt.Errorf("failed to read user policy file %s: %w", policyFilePath, err)
diff --git a/opkssh/scripts/install-linux.sh b/opkssh/scripts/install-linux.sh
index 98c202c..115299a 100644
--- a/opkssh/scripts/install-linux.sh
+++ b/opkssh/scripts/install-linux.sh
@@ -10,6 +10,7 @@ BINARY_NAME="opkssh"
 GITHUB_REPO="openpubkey/openpubkey"
 PROVIDER_GOOGLE="https://accounts.google.com 411517154569-7f10v0ftgp5elms1q8fm7avtp33t7i7n.apps.googleusercontent.com 24h"
 PROVIDER_MICROSOFT="https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0 096ce0a3-5e72-4da8-9c86-12924b294a01 24h"
+PROVIDER_GITLAB="https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h"
 
 # Ensure wget is installed
 if ! command -v wget &> /dev/null; then
@@ -63,6 +64,7 @@ if command -v $BINARY_NAME &> /dev/null; then
     else
         echo "$PROVIDER_GOOGLE" >> /etc/opk/providers
         echo "$PROVIDER_MICROSOFT" >> /etc/opk/providers
+        echo "$PROVIDER_GITLAB" >> /etc/opk/providers
     fi
 
     sed -i '/^AuthorizedKeysCommand /s/^/#/' /etc/ssh/sshd_config
diff --git a/providers/gitlab.go b/providers/gitlab.go
new file mode 100644
index 0000000..3047cbb
--- /dev/null
+++ b/providers/gitlab.go
@@ -0,0 +1,97 @@
+// Copyright 2025 OpenPubkey
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+package providers
+
+import (
+	"context"
+	"net/http"
+	"time"
+
+	"github.com/openpubkey/openpubkey/discover"
+)
+
+type GitlabOptions struct {
+	// ClientID is the client ID of the OIDC application. It should be the
+	// expected "aud" claim in received ID tokens from the OP.
+	ClientID string
+	// ClientSecret is the client secret of the OIDC application. Some OPs do
+	// not require that this value is set.
+	ClientSecret string
+	// Issuer is the OP's issuer URI for performing OIDC authorization and
+	// discovery.
+	Issuer string
+	// Scopes is the list of scopes to send to the OP in the initial
+	// authorization request.
+	Scopes []string
+	// RedirectURIs is the list of authorized redirect URIs that can be
+	// redirected to by the OP after the user completes the authorization code
+	// flow exchange. Ensure that your OIDC application is configured to accept
+	// these URIs otherwise an error may occur.
+	RedirectURIs []string
+	// GQSign denotes if the received ID token should be upgraded to a GQ token
+	// using GQ signatures.
+	GQSign bool
+	// OpenBrowser denotes if the client's default browser should be opened
+	// automatically when performing the OIDC authorization flow. This value
+	// should typically be set to true, unless performing some headless
+	// automation (e.g. integration tests) where you don't want the browser to
+	// open.
+	OpenBrowser bool
+	// HttpClient is the http.Client to use when making queries to the OP (OIDC
+	// code exchange, refresh, verification of ID token, fetch of JWKS endpoint,
+	// etc.). If nil, then http.DefaultClient is used.
+	HttpClient *http.Client
+	// IssuedAtOffset configures the offset to add when validating the "iss" and
+	// "exp" claims of received ID tokens from the OP.
+	IssuedAtOffset time.Duration
+}
+
+func GetDefaultGitlabOpOptions() *GitlabOptions {
+	return &GitlabOptions{
+		ClientID: "8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923",
+		Issuer:   gitlabIssuer,
+		Scopes:   []string{"openid email"},
+		RedirectURIs: []string{
+			"http://localhost:3000/login-callback",
+			"http://localhost:10001/login-callback",
+			"http://localhost:11110/login-callback",
+		},
+		GQSign:         false,
+		OpenBrowser:    true,
+		HttpClient:     nil,
+		IssuedAtOffset: 1 * time.Minute,
+	}
+}
+
+func NewGitlabOpWithOptions(opts *GitlabOptions) BrowserOpenIdProvider {
+	return &StandardOp{
+		clientID:                  opts.ClientID,
+		Scopes:                    opts.Scopes,
+		RedirectURIs:              opts.RedirectURIs,
+		GQSign:                    opts.GQSign,
+		OpenBrowser:               opts.OpenBrowser,
+		HttpClient:                opts.HttpClient,
+		IssuedAtOffset:            opts.IssuedAtOffset,
+		issuer:                    opts.Issuer,
+		requestTokensOverrideFunc: nil,
+		publicKeyFinder: discover.PublicKeyFinder{
+			JwksFunc: func(ctx context.Context, issuer string) ([]byte, error) {
+				return discover.GetJwksByIssuer(ctx, issuer, opts.HttpClient)
+			},
+		},
+	}
+}


Test patch:
diff --git a/client/choosers/web_chooser_test.go b/client/choosers/web_chooser_test.go
index 1b09547..89666e3 100644
--- a/client/choosers/web_chooser_test.go
+++ b/client/choosers/web_chooser_test.go
@@ -38,6 +38,7 @@ func TestGoogleSelection(t *testing.T) {
 	}{
 		{name: "select google", providerName: "google", httpCodeExpected: http.StatusOK},
 		{name: "select azure", providerName: "azure", httpCodeExpected: http.StatusOK},
+		{name: "select gitlab", providerName: "gitlab", httpCodeExpected: http.StatusOK},
 		{name: "select bad provider", providerName: "fakeProvider", httpCodeExpected: http.StatusBadRequest, errorString: "unknown OpenID Provider"},
 		{name: "select no provider", providerName: "", httpCodeExpected: http.StatusBadRequest, errorString: "missing op parameter"},
 	}
@@ -50,8 +51,13 @@ func TestGoogleSelection(t *testing.T) {
 			azureOpOptions := providers.GetDefaultAzureOpOptions()
 			azureOp := providers.NewAzureOpWithOptions(azureOpOptions)
 
+			gitlabOpOptions := providers.GetDefaultGitlabOpOptions()
+			gitlabOp := providers.NewGitlabOpWithOptions(gitlabOpOptions)
+
 			webChooser := WebChooser{
-				OpList:        []providers.BrowserOpenIdProvider{googleOp, azureOp},
+				OpList: []providers.BrowserOpenIdProvider{
+					googleOp, azureOp, gitlabOp,
+				},
 				OpenBrowser:   false,
 				useMockServer: true,
 			}
@@ -81,6 +87,8 @@ func TestGoogleSelection(t *testing.T) {
 					googleOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri)
 				case "azure":
 					azureOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri)
+				case "gitlab":
+					gitlabOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri)
 				default:
 					// Trigger azure even if the provider doesn't match to sure this test finishes
 					azureOp.(*providers.StandardOp).TriggerBrowserWindowHook(redirectUri)
@@ -163,6 +171,10 @@ func TestIssuerToName(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, "azure", name)
 
+	name, err = IssuerToName("https://gitlab.com")
+	require.NoError(t, err)
+	require.Equal(t, "gitlab", name)
+
 	name, err = IssuerToName("https://error.example.com")
 	require.ErrorContains(t, err, "unknown OpenID Provider")
 	require.Equal(t, "", name)
diff --git a/opkssh/policy/providerpolicy_test.go b/opkssh/policy/providerpolicy_test.go
index 9afe616..8d5df78 100644
--- a/opkssh/policy/providerpolicy_test.go
+++ b/opkssh/policy/providerpolicy_test.go
@@ -95,6 +95,18 @@ func TestProviderPolicy_CreateVerifier_Azure(t *testing.T) {
 	require.NotNil(t, ver)
 }
 
+func TestProviderPolicy_CreateVerifier_Gitlab(t *testing.T) {
+	policy := &ProviderPolicy{}
+	policy.AddRow(ProvidersPolicyRow{
+		Issuer:           "https://gitlab.com",
+		ClientID:         "test-gitlab",
+		ExpirationPolicy: "24h",
+	})
+	ver, err := policy.CreateVerifier()
+	require.NoError(t, err)
+	require.NotNil(t, ver)
+}
+
 // Test ProviderPolicy.CreateVerifier with an unsupported issuer.
 func TestProviderPolicy_CreateVerifier_UnsupportedIssuer(t *testing.T) {
 	policy := &ProviderPolicy{}