174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type oidcDiscoveryDocument struct {
|
||
|
|
Issuer string `json:"issuer"`
|
||
|
|
JWKSURI string `json:"jwks_uri"`
|
||
|
|
TokenEndpoint string `json:"token_endpoint"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func readSecret(path string) (string, error) {
|
||
|
|
content, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
value := strings.TrimSpace(string(content))
|
||
|
|
if value == "" {
|
||
|
|
return "", errors.New("secret file is empty")
|
||
|
|
}
|
||
|
|
return value, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchJSON(ctx context.Context, client *http.Client, target string, destination any) error {
|
||
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
response, err := client.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
defer func() { _ = response.Body.Close() }()
|
||
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||
|
|
return responseStatusError("OIDC endpoint", response)
|
||
|
|
}
|
||
|
|
return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(destination)
|
||
|
|
}
|
||
|
|
|
||
|
|
func discoverOIDC(ctx context.Context, client *http.Client, issuer string) (oidcDiscoveryDocument, error) {
|
||
|
|
issuerURL, err := url.Parse(issuer)
|
||
|
|
if err != nil || issuerURL.Scheme == "" || issuerURL.Host == "" {
|
||
|
|
return oidcDiscoveryDocument{}, errors.New("OIDC issuer is invalid")
|
||
|
|
}
|
||
|
|
target := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration"
|
||
|
|
var document oidcDiscoveryDocument
|
||
|
|
if err := fetchJSON(ctx, client, target, &document); err != nil {
|
||
|
|
return oidcDiscoveryDocument{}, err
|
||
|
|
}
|
||
|
|
if strings.TrimRight(document.Issuer, "/") != strings.TrimRight(issuer, "/") {
|
||
|
|
return oidcDiscoveryDocument{}, errors.New("OIDC discovery issuer does not match")
|
||
|
|
}
|
||
|
|
for _, endpoint := range []string{document.JWKSURI, document.TokenEndpoint} {
|
||
|
|
endpointURL, err := url.Parse(endpoint)
|
||
|
|
if err != nil || endpointURL.Scheme != issuerURL.Scheme || endpointURL.Host != issuerURL.Host {
|
||
|
|
return oidcDiscoveryDocument{}, errors.New("OIDC endpoint origin does not match issuer")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return document, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func oidcAccessToken(ctx context.Context, issuer, audience, clientID, clientSecret string) (string, error) {
|
||
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
||
|
|
discovery, err := discoverOIDC(ctx, client, issuer)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
form := url.Values{
|
||
|
|
"grant_type": {"client_credentials"},
|
||
|
|
"scope": {"openid urn:zitadel:iam:org:project:id:" + audience + ":aud"},
|
||
|
|
}
|
||
|
|
request, err := http.NewRequestWithContext(
|
||
|
|
ctx,
|
||
|
|
http.MethodPost,
|
||
|
|
discovery.TokenEndpoint,
|
||
|
|
strings.NewReader(form.Encode()),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
request.SetBasicAuth(clientID, clientSecret)
|
||
|
|
response, err := client.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
defer func() { _ = response.Body.Close() }()
|
||
|
|
if response.StatusCode != http.StatusOK {
|
||
|
|
return "", responseStatusError("OIDC token endpoint", response, clientSecret)
|
||
|
|
}
|
||
|
|
var tokenResponse struct {
|
||
|
|
AccessToken string `json:"access_token"`
|
||
|
|
}
|
||
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenResponse); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if tokenResponse.AccessToken == "" {
|
||
|
|
return "", errors.New("OIDC token response has no access token")
|
||
|
|
}
|
||
|
|
return tokenResponse.AccessToken, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func oidcTokenFromFiles(ctx context.Context, issuerFile, audienceFile, clientIDFile, clientSecretFile string) (string, error) {
|
||
|
|
issuer, err := readSecret(issuerFile)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("read OIDC issuer: %w", err)
|
||
|
|
}
|
||
|
|
audience, err := readSecret(audienceFile)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("read OIDC audience: %w", err)
|
||
|
|
}
|
||
|
|
clientID, err := readSecret(clientIDFile)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("read OIDC client ID: %w", err)
|
||
|
|
}
|
||
|
|
clientSecret, err := readSecret(clientSecretFile)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("read OIDC client secret: %w", err)
|
||
|
|
}
|
||
|
|
return oidcAccessToken(ctx, issuer, audience, clientID, clientSecret)
|
||
|
|
}
|
||
|
|
|
||
|
|
func oidcEnvironment(ctx context.Context, value options) ([]string, error) {
|
||
|
|
if value.oidcIssuerFile == "" {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
token, err := oidcTokenFromFiles(
|
||
|
|
ctx,
|
||
|
|
value.oidcIssuerFile,
|
||
|
|
value.oidcAudienceFile,
|
||
|
|
value.oidcClientIDFile,
|
||
|
|
value.oidcClientSecretFile,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return []string{"STALWART_TOKEN=" + token}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func waitForOIDC(ctx context.Context, issuerFile string, interval time.Duration) error {
|
||
|
|
issuer, err := readSecret(issuerFile)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
||
|
|
for {
|
||
|
|
discovery, err := discoverOIDC(ctx, client, issuer)
|
||
|
|
if err == nil {
|
||
|
|
var keys struct {
|
||
|
|
Keys []json.RawMessage `json:"keys"`
|
||
|
|
}
|
||
|
|
if fetchJSON(ctx, client, discovery.JWKSURI, &keys) == nil && len(keys.Keys) != 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
timer := time.NewTimer(interval)
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
timer.Stop()
|
||
|
|
return ctx.Err()
|
||
|
|
case <-timer.C:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|