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
15 changes: 14 additions & 1 deletion api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,21 @@ func getOidcProvider(id string, ctx context.Context, redirectPath string) (*oidc
}
}

endpoint := oidcProvider.Endpoint()

// Configure token endpoint authentication method based on provider settings
switch provider.TokenEndpointAuthMethod {
case "client_secret_post":
endpoint.AuthStyle = oauth2.AuthStyleInParams
case "client_secret_basic":
endpoint.AuthStyle = oauth2.AuthStyleInHeader
default:
// Use auto-detect for empty or unrecognized values (maintains backward compatibility)
endpoint.AuthStyle = oauth2.AuthStyleAutoDetect
}

oauthConfig := oauth2.Config{
Endpoint: oidcProvider.Endpoint(),
Endpoint: endpoint,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: provider.RedirectURL + redirectPath,
Expand Down
113 changes: 113 additions & 0 deletions api/login_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package api

import (
"context"
"encoding/json"
"testing"

"github.com/semaphoreui/semaphore/util"
"golang.org/x/oauth2"
)

func TestParseClaim(t *testing.T) {
Expand Down Expand Up @@ -83,3 +88,111 @@ func TestParseClaim5(t *testing.T) {
t.Fatalf("Expected: %v, Got: %v", "123456757343", res)
}
}

func TestTokenEndpointAuthMethod_ClientSecretPost(t *testing.T) {
// Setup test provider configuration using JSON to handle unexported fields
providerJSON := `{
"test": {
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"token_endpoint_auth_method": "client_secret_post",
"endpoint": {
"issuer": "https://example.com",
"auth": "https://example.com/auth",
"token": "https://example.com/token"
}
}
}`

var providers map[string]util.OidcProvider
if err := json.Unmarshal([]byte(providerJSON), &providers); err != nil {
t.Fatalf("Failed to parse provider JSON: %v", err)
}

util.Config = &util.ConfigType{
OidcProviders: providers,
}

ctx := context.Background()
_, oauthConfig, err := getOidcProvider("test", ctx, "")

if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}

if oauthConfig.Endpoint.AuthStyle != oauth2.AuthStyleInParams {
t.Fatalf("Expected AuthStyle to be AuthStyleInParams (1), got: %d", oauthConfig.Endpoint.AuthStyle)
}
}

func TestTokenEndpointAuthMethod_ClientSecretBasic(t *testing.T) {
// Setup test provider configuration using JSON to handle unexported fields
providerJSON := `{
"test": {
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"token_endpoint_auth_method": "client_secret_basic",
"endpoint": {
"issuer": "https://example.com",
"auth": "https://example.com/auth",
"token": "https://example.com/token"
}
}
}`

var providers map[string]util.OidcProvider
if err := json.Unmarshal([]byte(providerJSON), &providers); err != nil {
t.Fatalf("Failed to parse provider JSON: %v", err)
}

util.Config = &util.ConfigType{
OidcProviders: providers,
}

ctx := context.Background()
_, oauthConfig, err := getOidcProvider("test", ctx, "")

if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}

if oauthConfig.Endpoint.AuthStyle != oauth2.AuthStyleInHeader {
t.Fatalf("Expected AuthStyle to be AuthStyleInHeader (2), got: %d", oauthConfig.Endpoint.AuthStyle)
}
}

func TestTokenEndpointAuthMethod_Default(t *testing.T) {
// Setup test provider configuration with no auth method specified using JSON
providerJSON := `{
"test": {
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"endpoint": {
"issuer": "https://example.com",
"auth": "https://example.com/auth",
"token": "https://example.com/token"
}
}
}`

var providers map[string]util.OidcProvider
if err := json.Unmarshal([]byte(providerJSON), &providers); err != nil {
t.Fatalf("Failed to parse provider JSON: %v", err)
}

util.Config = &util.ConfigType{
OidcProviders: providers,
}

ctx := context.Background()
_, oauthConfig, err := getOidcProvider("test", ctx, "")

if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}

if oauthConfig.Endpoint.AuthStyle != oauth2.AuthStyleAutoDetect {
t.Fatalf("Expected AuthStyle to be AuthStyleAutoDetect (0), got: %d", oauthConfig.Endpoint.AuthStyle)
}
}

60 changes: 60 additions & 0 deletions examples/oidc-okta/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Okta OIDC Configuration for Semaphore

This example demonstrates how to configure Semaphore to use Okta as an OpenID Connect (OIDC) authentication provider.

## Problem Addressed

Some OIDC providers, including Okta, require the use of `client_secret_post` authentication method instead of the default `client_secret_basic` method. The `token_endpoint_auth_method` configuration option allows you to specify which authentication method to use.

## Configuration

Copy the `config.json` file and update the following fields:

- `YOUR_COOKIE_HASH_HERE`: Generate a secure random string (base64 encoded, 32 bytes)
- `YOUR_COOKIE_ENCRYPTION_HERE`: Generate another secure random string (base64 encoded, 32 bytes)
- `your-domain.okta.com`: Your Okta domain
- `YOUR_CLIENT_ID`: Your Okta application's Client ID
- `YOUR_CLIENT_SECRET`: Your Okta application's Client Secret
- `your-semaphore-instance.com`: Your Semaphore instance's hostname

### Token Endpoint Authentication Method

The `token_endpoint_auth_method` field supports the following values:

- `"client_secret_post"` - Sends client credentials in the request body (required by Okta)
- `"client_secret_basic"` - Sends client credentials in the Authorization header (default OAuth2 behavior)
- Empty or omitted - Auto-detect based on provider's configuration

## Okta Configuration

In your Okta application settings:

1. Set the **Sign-in redirect URIs** to: `https://your-semaphore-instance.com/api/auth/oidc/okta/redirect`
2. Set the **Client authentication** method to: **Client secret (Confidential)**
3. Ensure **Token endpoint authentication method** is set to: **Client Secret sent in HTTP POST body** (`client_secret_post`)

## Testing

After configuring, you can test the OIDC authentication by:

1. Starting Semaphore: `./semaphore server --config config.json`
2. Navigate to the login page at `https://your-semaphore-instance.com`
3. You should see an "Okta" login button
4. Click it to authenticate via Okta

## Troubleshooting

If you encounter `invalid_client` errors:

- Verify the `client_id` and `client_secret` are correct
- Ensure the `token_endpoint_auth_method` matches your Okta application's configuration
- Check that the redirect URI in Semaphore matches what's configured in Okta
- Review the Semaphore logs for detailed error messages

## Environment Variables

Alternatively, you can configure OIDC providers using environment variables:

```bash
export SEMAPHORE_OIDC_PROVIDERS='{"okta":{"display_name":"Okta","provider_url":"https://your-domain.okta.com","client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET","redirect_url":"https://your-semaphore-instance.com/api/auth/oidc/okta/redirect","scopes":["openid","profile","email"],"token_endpoint_auth_method":"client_secret_post","username_claim":"preferred_username","name_claim":"name","email_claim":"email","order":0}}'
```
24 changes: 24 additions & 0 deletions examples/oidc-okta/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"bolt": {
"host": "/path/to/database.boltdb"
},
"dialect": "bolt",
"cookie_hash": "YOUR_COOKIE_HASH_HERE",
"cookie_encryption": "YOUR_COOKIE_ENCRYPTION_HERE",
"web_host": "https://your-semaphore-instance.com",
"oidc_providers": {
"okta": {
"display_name": "Okta",
"provider_url": "https://your-domain.okta.com",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_url": "https://your-semaphore-instance.com/api/auth/oidc/okta/redirect",
"scopes": ["openid", "profile", "email"],
"token_endpoint_auth_method": "client_secret_post",
"username_claim": "preferred_username",
"name_claim": "name",
"email_claim": "email",
"order": 0
}
}
}
31 changes: 16 additions & 15 deletions util/OdbcProvider.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package util

type OidcProvider struct {
ClientID string `json:"client_id"`
ClientIDFile string `json:"client_id_file"`
ClientSecret string `json:"client_secret"`
ClientSecretFile string `json:"client_secret_file"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoDiscovery string `json:"provider_url"`
Endpoint oidcEndpoint `json:"endpoint"`
UsernameClaim string `json:"username_claim" default:"preferred_username"`
NameClaim string `json:"name_claim" default:"preferred_username"`
EmailClaim string `json:"email_claim" default:"email"`
Order int `json:"order"`
ClientID string `json:"client_id"`
ClientIDFile string `json:"client_id_file"`
ClientSecret string `json:"client_secret"`
ClientSecretFile string `json:"client_secret_file"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoDiscovery string `json:"provider_url"`
Endpoint oidcEndpoint `json:"endpoint"`
UsernameClaim string `json:"username_claim" default:"preferred_username"`
NameClaim string `json:"name_claim" default:"preferred_username"`
EmailClaim string `json:"email_claim" default:"email"`
Order int `json:"order"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
}

type ClaimsProvider interface {
Expand Down
Loading