Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ opkssh add root alice@example.com https://authentik.local/application/o/opkssh/
| [AWS Cognito](https://aws.amazon.com/cognito/) | ✅ | [Amazon Cognito Integration Guide](docs/providers/cognito.md) |
| [Azure](https://www.azure.com/) | ✅ | [Entra ID (Azure) Integration Guide](docs/providers/azure.md) |
| [GitHub Actions](https://github.com/features/actions) | ✅ | [GitHub Actions Guide](docs/github-actions.md) |
| [GitLab CI/CD](https://docs.gitlab.com/ci/) | ✅ | [GitLab CI Guide](docs/gitlab-ci.md) |
| [Gitlab Self-hosted](https://gitlab.com/) | ✅ | [Configuration guide](docs/gitlab-selfhosted.md) |
| [Kanidm](https://kanidm.com/) | ✅ | [Kanidm Integration Guide](https://kanidm.github.io/kanidm/master/integrations/oauth2/examples.html#opkssh)|
| [Keycloak](https://www.keycloak.org) | ✅ | [Keycloak Integration Guide](docs/providers/keycloak.md) |
Expand Down Expand Up @@ -652,6 +653,7 @@ For integration tests run:

### Guides
- [CONTRIBUTING.md](https://github.com/openpubkey/opkssh/blob/main/CONTRIBUTING.md) Guide to contributing to opkssh (includes developer help).
- [docs/gitlab-ci.md](docs/gitlab-ci.md) Guide to SSHing via GitLab CI/CD.
- [docs/gitlab-selfhosted.md](docs/gitlab-selfhosted.md) Guide on configuring and using a self hosted GitLab instance with opkssh.
- [docs/paramiko.md](docs/paramiko.md) Guide to using the Python SSH paramiko library with opkssh.
- [docs/putty.md](docs/putty.md) Guide to using PuTTY with opkssh.
Expand Down
26 changes: 25 additions & 1 deletion commands/config/providerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package config
import (
"fmt"
"os"
"slices"
"strings"

"github.com/openpubkey/openpubkey/providers"
Expand Down Expand Up @@ -121,6 +122,19 @@ func GitHubProviderConfig() ProviderConfig {
}
}

func GitLabCiProviderConfig() ProviderConfig {
issuer := os.Getenv("OPKSSH_GITLAB_CI_ISSUER")
if issuer == "" {
issuer = "https://gitlab.com"
}
return ProviderConfig{
AliasList: []string{"gitlab-ci"},
Issuer: issuer,
// This is required, but is not used for this provider.
ClientID: "unused",
}
}

// NewProviderConfigFromString is a function to create the provider config from a string of the format
// {alias},{provider_url},{client_id},{client_secret},{scopes}
func NewProviderConfigFromString(configStr string, hasAlias bool) (ProviderConfig, error) {
Expand Down Expand Up @@ -188,7 +202,13 @@ func (p *ProviderConfig) ToProvider(openBrowser bool) (providers.OpenIdProvider,
}
var provider providers.OpenIdProvider

if strings.HasPrefix(p.Issuer, "https://accounts.google.com") {
if p.hasAlias("gitlab-ci") {
if p.Issuer == "https://gitlab.com" {
provider = providers.NewGitlabCiOpFromEnvironmentDefault()
} else {
provider = providers.NewGitlabCiOp(p.Issuer, "OPENPUBKEY_JWT")
}
} else if strings.HasPrefix(p.Issuer, "https://accounts.google.com") {
opts := providers.GetDefaultGoogleOpOptions()
opts.Issuer = p.Issuer
opts.ClientID = p.ClientID
Expand Down Expand Up @@ -274,6 +294,10 @@ func (p *ProviderConfig) hasScopes() bool {
return len(p.Scopes) > 0 && (len(p.Scopes) > 1 || p.Scopes[0] != "")
}

func (p *ProviderConfig) hasAlias(alias string) bool {
return slices.Contains(p.AliasList, alias)
}

// GetProvidersConfigFromEnv is a function to retrieve the config from the env variables
// OPKSSH_DEFAULT can be set to an alias
// OPKSSH_PROVIDERS is a ; separated list of providers of the format <alias>,<issuer>,<client_id>,<client_secret>,<scopes>;<alias>,<issuer>,<client_id>,<client_secret>,<scopes>
Expand Down
22 changes: 22 additions & 0 deletions commands/config/providerconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"os"
"testing"

"github.com/openpubkey/openpubkey/providers"
"github.com/stretchr/testify/require"
)

Expand Down Expand Up @@ -56,6 +57,27 @@ func TestProvidersConfigFromStrings(t *testing.T) {
require.Nil(t, providerConfigs)
}

func TestGitLabCiProviderConfigToProvider(t *testing.T) {
providerConfig := GitLabCiProviderConfig()
provider, err := providerConfig.ToProvider(false)

require.NoError(t, err)
require.IsType(t, &providers.GitlabCiOp{}, provider)
require.Equal(t, "https://gitlab.com", provider.Issuer())
}

func TestGitLabCiProviderConfigWithCustomIssuerToProvider(t *testing.T) {
customIssuer := "https://gitlab.example.com"
t.Setenv("OPKSSH_GITLAB_CI_ISSUER", customIssuer)

providerConfig := GitLabCiProviderConfig()
provider, err := providerConfig.ToProvider(false)

require.NoError(t, err)
require.IsType(t, &providers.GitlabCiOp{}, provider)
require.Equal(t, customIssuer, provider.Issuer())
}

func TestProvidersConfigFromEnv(t *testing.T) {

tests := []struct {
Expand Down
18 changes: 17 additions & 1 deletion commands/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ func (l *LoginCmd) Run(ctx context.Context) error {
l.Config.Providers = append(l.Config.Providers, config.GitHubProviderConfig())
}

if isGitLabCiEnvironment() {
l.Config.Providers = append(l.Config.Providers, config.GitLabCiProviderConfig())
}

var provider providers.OpenIdProvider
if l.overrideProvider != nil {
provider = *l.overrideProvider
Expand Down Expand Up @@ -431,7 +435,15 @@ func (l *LoginCmd) determineProvider() (providers.OpenIdProvider, *choosers.WebC
if err != nil {
return nil, nil, fmt.Errorf("error creating provider from config: %w", err)
}
providerList = append(providerList, op.(providers.BrowserOpenIdProvider))
browserOp, ok := op.(providers.BrowserOpenIdProvider)
if !ok {
continue
}
providerList = append(providerList, browserOp)
}

if len(providerList) == 0 {
return nil, nil, fmt.Errorf("no browser-compatible providers specified")
}

chooser := choosers.NewWebChooser(
Expand Down Expand Up @@ -891,6 +903,10 @@ func isGitHubEnvironment() bool {
os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") != ""
}

func isGitLabCiEnvironment() bool {
return os.Getenv("GITLAB_CI") == "true" && os.Getenv("OPENPUBKEY_JWT") != ""
}

// payloadFromCompactPkt extracts the payload from a compact PK Token which
// is always the second part of the '.' separated string.
func payloadFromCompactPkt(compactPkt []byte) []byte {
Expand Down
199 changes: 199 additions & 0 deletions docs/gitlab-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# SSH via GitLab CI

opkssh supports SSHing into servers from GitLab CI/CD jobs using GitLab's OpenID Connect (OIDC) ID tokens. This allows CI/CD pipelines to authenticate over SSH without managing static SSH keys or long-lived secrets.

## How it works

GitLab CI/CD can issue [OIDC ID tokens](https://docs.gitlab.com/ci/secrets/id_token_authentication/) to a job. These tokens prove the identity of a pipeline run, including claims such as the project path, branch/tag ref, and pipeline source.

When `opkssh login gitlab-ci` runs inside a GitLab CI environment, opkssh reads the ID token from the `OPENPUBKEY_JWT` environment variable and uses it to create an SSH certificate. The SSH server verifies the certificate against the server-side provider configuration and policy.

GitLab CI tokens are verified using OpenPubkey's GitLab CI provider. These tokens are GQ-bound (`GQ256`) rather than using the normal browser-login nonce flow, so the server must have a GitLab CI provider entry in `/etc/opk/providers`.

## Server setup

### 1. Install opkssh on the server

Follow the standard [installation instructions](../README.md#install-opkssh-on-a-server) to install opkssh on your server.

### 2. Add the GitLab CI provider

Add a GitLab CI provider entry to `/etc/opk/providers`. The second field must be the OpenPubkey PKToken audience used by your GitLab CI job.

Using the explicit audience from the GitLab CI token:

```bash
echo "https://gitlab.com OPENPUBKEY-PKTOKEN:ssh-deploy-prod 24h" | sudo tee -a /etc/opk/providers
```

For self-managed GitLab, use your GitLab instance URL as the issuer instead:

```bash
echo "https://gitlab.example.com OPENPUBKEY-PKTOKEN:ssh-deploy-prod 24h" | sudo tee -a /etc/opk/providers
```

If you also want to allow normal interactive GitLab logins, keep the normal GitLab provider entry as well:

```text
https://gitlab.com 8d8b7024572c7fd501f64374dec6bba37096783dfcd792b3988104be08cb6923 24h
https://gitlab.com OPENPUBKEY-PKTOKEN:ssh-deploy-prod 24h
```

For self-managed GitLab, use the same issuer in `/etc/opk/auth_id` as in `/etc/opk/providers`.

### 3. Authorize a GitLab project and ref

Use `opkssh add` to allow a specific GitLab project and branch to SSH into the server as a given user. The identity typically matches GitLab's `sub` claim:

```text
project_path:<namespace>/<project>:ref_type:<branch|tag>:ref:<ref>
```

For example, to allow the `main` branch of `cgroschupp/opkssh-test` to log in as `root`:

```bash
sudo opkssh add root "project_path:cgroschupp/opkssh-test:ref_type:branch:ref:main" "https://gitlab.com"
```

This adds a line like this to `/etc/opk/auth_id`:

```text
root project_path:cgroschupp/opkssh-test:ref_type:branch:ref:main https://gitlab.com
```

For self-managed GitLab, use your instance issuer:

```bash
sudo opkssh add root "project_path:mygroup/myproject:ref_type:branch:ref:main" "https://gitlab.example.com"
```

Validate the server configuration:

```bash
sudo opkssh audit
sudo opkssh permissions check
```

## GitLab CI workflow

Your GitLab CI job needs an `id_tokens` entry that creates `OPENPUBKEY_JWT`. The `aud` value becomes part of the OpenPubkey PKToken audience.

Here is a minimal example that logs in with opkssh and SSHes into a remote server:

```yaml
stages:
- test

test-ssh:
id_tokens:
OPENPUBKEY_JWT:
aud: OPENPUBKEY-PKTOKEN:ssh-deploy-prod
image: ubuntu
stage: test
script:
- apt-get update && apt-get install -y curl openssh-client
- curl -L -o /usr/local/bin/opkssh https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-amd64
- chmod +x /usr/local/bin/opkssh
- opkssh login gitlab-ci
- opkssh inspect /root/.ssh/id_ecdsa-cert.pub
- ssh -o StrictHostKeyChecking=accept-new root@your-server.example.com "echo 'Hello from GitLab CI'"
```

The example downloads the latest Linux amd64 opkssh binary from the official GitHub release. For other architectures, use the matching asset from the [latest release](https://github.com/openpubkey/opkssh/releases/latest).

For self-managed GitLab, set `OPKSSH_GITLAB_CI_ISSUER` to your GitLab issuer URL before running `opkssh login gitlab-ci`:

```yaml
test-ssh:
variables:
OPKSSH_GITLAB_CI_ISSUER: https://gitlab.example.com
id_tokens:
OPENPUBKEY_JWT:
aud: OPENPUBKEY-PKTOKEN:ssh-deploy-prod
image: ubuntu
stage: test
script:
- apt-get update && apt-get install -y curl openssh-client
- curl -L -o /usr/local/bin/opkssh https://github.com/openpubkey/opkssh/releases/latest/download/opkssh-linux-amd64
- chmod +x /usr/local/bin/opkssh
- opkssh login gitlab-ci
- ssh -o StrictHostKeyChecking=accept-new root@your-server.example.com "echo 'Hello from GitLab CI'"
```

### Key workflow requirements

- **`id_tokens.OPENPUBKEY_JWT`**: This must be configured so GitLab creates an OIDC ID token and exposes it as the `OPENPUBKEY_JWT` environment variable.
- **Audience must match server policy**: If the job uses `aud: OPENPUBKEY-PKTOKEN:ssh-deploy-prod`, the server should have `https://gitlab.com OPENPUBKEY-PKTOKEN:ssh-deploy-prod 24h` in `/etc/opk/providers`.
- **GitLab CI claims are required**: The token must include GitLab CI-specific claims (`ci_config_ref_uri`, `job_id`, `job_project_path`, and `pipeline_id`). These claims distinguish GitLab CI tokens from normal interactive GitLab login tokens that share the same issuer.
- **`opkssh login gitlab-ci`**: The `gitlab-ci` argument tells opkssh to use the GitLab CI provider. It reads `OPENPUBKEY_JWT` from the environment.
- **`OPKSSH_GITLAB_CI_ISSUER`**: Optional. Set this to your self-managed GitLab issuer URL, such as `https://gitlab.example.com`. If unset, opkssh uses `https://gitlab.com`.
- **Server-side identity**: The identity in `/etc/opk/auth_id` must match the token's `sub` claim.

## Identity format

GitLab's default `sub` claim commonly has this format:

```text
project_path:<namespace>/<project>:ref_type:<branch|tag>:ref:<ref>
```

Examples:

| Pattern | Example |
|---------|---------|
| Project + branch | `project_path:mygroup/myproject:ref_type:branch:ref:main` |
| Project + tag | `project_path:mygroup/myproject:ref_type:tag:ref:v1.0.0` |

A GitLab CI token also contains additional claims such as `project_path`, `ref`, `ref_type`, `pipeline_source`, `job_project_path`, and `user_login`. The opkssh server policy usually authorizes the `sub` claim, but you can inspect the generated certificate to confirm the exact identity:

```bash
opkssh inspect /root/.ssh/id_ecdsa-cert.pub
```

Look for:

```text
Subject: project_path:cgroschupp/opkssh-test:ref_type:branch:ref:main
Issuer: https://gitlab.com
```

## Troubleshooting

**Login fails in GitLab CI**
Make sure the job defines `id_tokens.OPENPUBKEY_JWT` and runs `opkssh login gitlab-ci` inside GitLab CI. The environment must contain `GITLAB_CI=true` and `OPENPUBKEY_JWT`. For self-managed GitLab, also set `OPKSSH_GITLAB_CI_ISSUER` to the issuer URL used by your GitLab instance.

**SSH connection rejected**
Check the server policy and provider configuration:

```bash
sudo cat /etc/opk/auth_id
sudo cat /etc/opk/providers
sudo opkssh audit
```

Verify that the identity in `/etc/opk/auth_id` matches the `Subject` shown by `opkssh inspect`.

**Audience mismatch**
If the token audience is `OPENPUBKEY-PKTOKEN:ssh-deploy-prod`, add a matching provider line:

```text
https://gitlab.com OPENPUBKEY-PKTOKEN:ssh-deploy-prod 24h
```

**Normal GitLab login works but GitLab CI fails**
Normal GitLab logins use the browser/OIDC nonce flow. GitLab CI uses GQ-bound tokens (`GQ256`). Ensure the server has a GitLab CI provider line with an explicit `OPENPUBKEY-PKTOKEN:*` audience in `/etc/opk/providers`. The GitLab CI token must also include the required CI claims: `ci_config_ref_uri`, `job_id`, `job_project_path`, and `pipeline_id`.

**Verify fails during SSH authentication**
Check the opkssh server log:

```bash
sudo tail -n 200 /var/log/opkssh.log
```

You can also test verification manually on the server:

```bash
typ=$(awk '{print $1}' /root/.ssh/id_ecdsa-cert.pub)
cert=$(awk '{print $2}' /root/.ssh/id_ecdsa-cert.pub)
sudo -u opksshuser /usr/local/bin/opkssh verify root "$cert" "$typ"
```
Loading
Loading