initial commit
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Binaries and build artifacts
|
||||||
|
bin/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# Go compiler outputs
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency/vendor directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Coverage and profiling data
|
||||||
|
coverage.*
|
||||||
|
*.cov
|
||||||
|
*.coverprofile
|
||||||
|
*.pprof
|
||||||
|
*.prof
|
||||||
|
profile.out
|
||||||
|
|
||||||
|
# Local configs and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.local
|
||||||
|
*.secret.yaml
|
||||||
|
|
||||||
|
# IDE/editor state
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# OS junk
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM golang:1.22 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -o /out/broker ./cmd/broker
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
ENV CONFIG_PATH=/config/config.yaml
|
||||||
|
WORKDIR /
|
||||||
|
USER nonroot:nonroot
|
||||||
|
COPY --from=build /out/broker /broker
|
||||||
|
ENTRYPOINT ["/broker"]
|
||||||
19
Makefile
Normal file
19
Makefile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
APP?=saml-oidc-broker
|
||||||
|
PKG?=shamilnunhuck/saml-oidc-bridge
|
||||||
|
CONFIG?=example.config.yaml
|
||||||
|
KEY_ID?=k-$(shell date +%Y-%m)
|
||||||
|
|
||||||
|
build:
|
||||||
|
GO111MODULE=on CGO_ENABLED=0 go build -o bin/$(APP) ./cmd/broker
|
||||||
|
|
||||||
|
run:
|
||||||
|
CONFIG_PATH=$(CONFIG) bin/$(APP)
|
||||||
|
|
||||||
|
rotate-key:
|
||||||
|
bin/$(APP) cert -config $(CONFIG) -id $(KEY_ID) -algo rsa3072 -days 825 -cn id.example.com -org "YourOrg" -k8s-secret-out build/$(KEY_ID).secret.yaml
|
||||||
|
@echo "Wrote build/$(KEY_ID).secret.yaml"
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --platform linux/amd64 -t shamilnunhuck/$(APP):dev .
|
||||||
|
|
||||||
|
.PHONY: build run rotate-key docker
|
||||||
6
charts/saml-broker/Chart.yaml
Normal file
6
charts/saml-broker/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: saml-broker
|
||||||
|
description: Minimal SAML IdP brokering to OIDC (Pocket ID) for Splunk
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "0.1.0"
|
||||||
4
charts/saml-broker/templates/NOTES.txt
Normal file
4
charts/saml-broker/templates/NOTES.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
1. Get the service URL by running these commands:
|
||||||
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "saml-broker.name" . }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
kubectl port-forward $POD_NAME 8080:8080 &
|
||||||
|
echo "Visit http://127.0.0.1:8080/saml/metadata"
|
||||||
10
charts/saml-broker/templates/_helpers.tpl
Normal file
10
charts/saml-broker/templates/_helpers.tpl
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{- define "saml-broker.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- define "saml-broker.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride -}}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- printf "%s" (include "saml-broker.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
7
charts/saml-broker/templates/configmap.yaml
Normal file
7
charts/saml-broker/templates/configmap.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "saml-broker.fullname" . }}-config
|
||||||
|
data:
|
||||||
|
config.yaml: |
|
||||||
|
{{ toYaml .Values.config | indent 4 }}
|
||||||
49
charts/saml-broker/templates/deployment.yaml
Normal file
49
charts/saml-broker/templates/deployment.yaml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "saml-broker.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ include "saml-broker.name" . }}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: {{ include "saml-broker.name" . }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: {{ include "saml-broker.name" . }}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: broker
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
env:
|
||||||
|
- name: CONFIG_PATH
|
||||||
|
value: /config/config.yaml
|
||||||
|
- name: OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ .Values.env.OIDC_CLIENT_SECRET_SECRET_NAME }}
|
||||||
|
key: {{ .Values.env.OIDC_CLIENT_SECRET_KEY }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
volumeMounts:
|
||||||
|
- name: cfg
|
||||||
|
mountPath: /config
|
||||||
|
readOnly: true
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
resources:
|
||||||
|
{{ toYaml .Values.resources | indent 12 }}
|
||||||
|
volumes:
|
||||||
|
- name: cfg
|
||||||
|
configMap:
|
||||||
|
name: {{ include "saml-broker.fullname" . }}-config
|
||||||
30
charts/saml-broker/templates/ingress.yaml
Normal file
30
charts/saml-broker/templates/ingress.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "saml-broker.fullname" . }}
|
||||||
|
{{- with .Values.ingress.className }}
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: {{ . }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "saml-broker.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{ toYaml .Values.ingress.tls | indent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
13
charts/saml-broker/templates/service.yaml
Normal file
13
charts/saml-broker/templates/service.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "saml-broker.fullname" . }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: {{ include "saml-broker.name" . }}
|
||||||
63
charts/saml-broker/values.yaml
Normal file
63
charts/saml-broker/values.yaml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
image:
|
||||||
|
repository: ghcr.io/your-org/broker
|
||||||
|
tag: dev
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
hosts:
|
||||||
|
- host: id.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
# OIDC client secret comes from a Secret
|
||||||
|
OIDC_CLIENT_SECRET_SECRET_NAME: oidc-secret
|
||||||
|
OIDC_CLIENT_SECRET_KEY: OIDC_CLIENT_SECRET
|
||||||
|
|
||||||
|
config:
|
||||||
|
# Paste example.config.yaml here (without private key if you mount keys via secret)
|
||||||
|
server:
|
||||||
|
listen: ":8080"
|
||||||
|
external_url: "https://id.example.com"
|
||||||
|
crypto:
|
||||||
|
active_key: "k-2025-09"
|
||||||
|
keys: []
|
||||||
|
oidc_upstream:
|
||||||
|
issuer: "https://pocket-id.example"
|
||||||
|
client_id: "your-client-id"
|
||||||
|
redirect_path: "/oidc/callback"
|
||||||
|
scopes: ["email","profile"]
|
||||||
|
sps:
|
||||||
|
- name: "splunk"
|
||||||
|
entity_id: "https://splunk.example"
|
||||||
|
acs_url: "https://splunk.example/saml/acs"
|
||||||
|
audience: "https://splunk.example"
|
||||||
|
nameid_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||||
|
attribute_mapping:
|
||||||
|
mail: "email"
|
||||||
|
realName: "name"
|
||||||
|
role: "role"
|
||||||
|
role_mapping:
|
||||||
|
admins: "admin"
|
||||||
|
power: "power"
|
||||||
|
"*": "user"
|
||||||
|
security:
|
||||||
|
skew_seconds: 120
|
||||||
|
assertion_ttl_seconds: 300
|
||||||
|
require_signed_authn_request: false
|
||||||
|
metadata_valid_until_days: 7
|
||||||
|
metadata_cache_duration_seconds: 86400
|
||||||
|
session:
|
||||||
|
cookie_name: "_saml_broker"
|
||||||
|
cookie_secure: true
|
||||||
|
cookie_domain: "id.example.com"
|
||||||
114
cmd/broker/main.go
Normal file
114
cmd/broker/main.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/cli"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/crypto"
|
||||||
|
h "shamilnunhuck/saml-oidc-bridge/internal/http"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/oidc"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type runtimeState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cfg *config.Config
|
||||||
|
ks *crypto.KeyStore
|
||||||
|
idp *saml.IdP
|
||||||
|
oidc *oidc.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "cert" {
|
||||||
|
if err := cli.RunCert(os.Args[2:]); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfgPath := os.Getenv("CONFIG_PATH")
|
||||||
|
if cfgPath == "" {
|
||||||
|
cfgPath = "example.config.yaml"
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &runtimeState{}
|
||||||
|
load := func() {
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OIDC_CLIENT_SECRET"); v != "" {
|
||||||
|
cfg.OIDC.ClientSecret = v
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
log.Fatalf("invalid config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ks, err := crypto.NewKeyStore(cfg.Crypto)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("keystore: %v", err)
|
||||||
|
}
|
||||||
|
idp := saml.NewIdP(cfg, ks)
|
||||||
|
oc, err := oidc.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("oidc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.mu.Lock()
|
||||||
|
state.cfg, state.ks, state.idp, state.oidc = cfg, ks, idp, oc
|
||||||
|
state.mu.Unlock()
|
||||||
|
log.Printf("loaded config; active signing key=%s", cfg.Crypto.ActiveKey)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.Register(
|
||||||
|
mux,
|
||||||
|
func() *config.Config { state.mu.RLock(); defer state.mu.RUnlock(); return state.cfg },
|
||||||
|
func() *saml.IdP { state.mu.RLock(); defer state.mu.RUnlock(); return state.idp },
|
||||||
|
func() *oidc.Client { state.mu.RLock(); defer state.mu.RUnlock(); return state.oidc },
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("listening on %s", state.cfg.Server.Listen)
|
||||||
|
log.Fatal(http.ListenAndServe(state.cfg.Server.Listen, mux))
|
||||||
|
}()
|
||||||
|
|
||||||
|
sigc := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigc, syscall.SIGHUP)
|
||||||
|
go func() {
|
||||||
|
for range sigc {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w, err := fsnotify.NewWatcher()
|
||||||
|
if err == nil {
|
||||||
|
defer w.Close()
|
||||||
|
_ = w.Add(cfgPath)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case e := <-w.Events:
|
||||||
|
if e.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) != 0 {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
case err := <-w.Errors:
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("watch error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {}
|
||||||
|
}
|
||||||
43
example.config.yaml
Normal file
43
example.config.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
server:
|
||||||
|
listen: :8080
|
||||||
|
external_url: https://saml-v.ttt.net
|
||||||
|
crypto:
|
||||||
|
active_key: k-2025-12
|
||||||
|
keys:
|
||||||
|
- id: k-2025-12
|
||||||
|
cert_pem: |
|
||||||
|
...
|
||||||
|
key_pem: |
|
||||||
|
...
|
||||||
|
not_after: 2028-01-06T12:27:11.670644Z
|
||||||
|
oidc_upstream:
|
||||||
|
issuer: https://id.tt.net
|
||||||
|
client_id: 1ec56384
|
||||||
|
redirect_path: /oidc/callback
|
||||||
|
scopes:
|
||||||
|
- email
|
||||||
|
- profile
|
||||||
|
sps:
|
||||||
|
- name: splunk
|
||||||
|
entity_id: https://splunk.example
|
||||||
|
acs_url: https://splunk.example/saml/acs
|
||||||
|
audience: https://splunk.example
|
||||||
|
nameid_format: urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||||
|
attribute_mapping:
|
||||||
|
mail: email
|
||||||
|
realName: name
|
||||||
|
role: role
|
||||||
|
role_mapping:
|
||||||
|
'*': user
|
||||||
|
admins: admin
|
||||||
|
power: power
|
||||||
|
security:
|
||||||
|
skew_seconds: 120
|
||||||
|
assertion_ttl_seconds: 300
|
||||||
|
require_signed_authn_request: false
|
||||||
|
metadata_valid_until_days: 7
|
||||||
|
metadata_cache_duration_seconds: 86400
|
||||||
|
session:
|
||||||
|
cookie_name: _saml_broker
|
||||||
|
cookie_secure: true
|
||||||
|
cookie_domain: saml-v.ttt.net
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module shamilnunhuck/saml-oidc-bridge
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0
|
||||||
|
github.com/crewjam/saml v0.5.1
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
|
golang.org/x/oauth2 v0.23.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beevik/etree v1.5.0 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
)
|
||||||
54
go.sum
Normal file
54
go.sum
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||||
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
|
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
||||||
|
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c=
|
||||||
|
github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME=
|
||||||
|
github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI=
|
||||||
|
github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM=
|
||||||
|
github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||||
|
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||||
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
206
internal/cli/cert.go
Normal file
206
internal/cli/cert.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rotateOpts struct {
|
||||||
|
ConfigPath string
|
||||||
|
ID string
|
||||||
|
Algo string
|
||||||
|
Days int
|
||||||
|
CN string
|
||||||
|
Org string
|
||||||
|
OutK8s string
|
||||||
|
ActiveOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunCert(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("cert", flag.ContinueOnError)
|
||||||
|
var ro rotateOpts
|
||||||
|
fs.StringVar(&ro.ConfigPath, "config", "example.config.yaml", "path to config yaml")
|
||||||
|
fs.StringVar(&ro.ID, "id", "", "key id (e.g. k-2025-10)")
|
||||||
|
fs.StringVar(&ro.Algo, "algo", "rsa3072", "rsa2048|rsa3072|rsa4096|p256|p384")
|
||||||
|
fs.IntVar(&ro.Days, "days", 825, "validity in days")
|
||||||
|
fs.StringVar(&ro.CN, "cn", "id.example.com", "certificate CN")
|
||||||
|
fs.StringVar(&ro.Org, "org", "YourOrg", "certificate O")
|
||||||
|
fs.StringVar(&ro.OutK8s, "k8s-secret-out", "", "write a Kubernetes Secret manifest to this path")
|
||||||
|
fs.BoolVar(&ro.ActiveOnly, "active-only", false, "only set active_key to -id (no new cert)")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ro.ID == "" {
|
||||||
|
return errors.New("missing -id")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, raw, err := loadConfig(ro.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ro.ActiveOnly {
|
||||||
|
cfg.Crypto.ActiveKey = ro.ID
|
||||||
|
return saveConfig(ro.ConfigPath, raw, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM, keyPEM, notAfter, err := genSelfSigned(ro)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Crypto.Keys = append(cfg.Crypto.Keys, config.KeyPair{
|
||||||
|
ID: ro.ID,
|
||||||
|
CertPEM: string(certPEM),
|
||||||
|
KeyPEM: string(keyPEM),
|
||||||
|
NotAfter: notAfter.UTC(),
|
||||||
|
})
|
||||||
|
cfg.Crypto.ActiveKey = ro.ID
|
||||||
|
|
||||||
|
if err := saveConfig(ro.ConfigPath, raw, cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ro.OutK8s != "" {
|
||||||
|
if err := os.WriteFile(ro.OutK8s, []byte(k8sSecretYAML(ro, certPEM, keyPEM)), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("OK: generated %s (algo=%s, not_after=%s) and set active_key\n", ro.ID, ro.Algo, notAfter.UTC().Format(time.RFC3339))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func genSelfSigned(ro rotateOpts) (certPEM, keyPEM []byte, notAfter time.Time, err error) {
|
||||||
|
nb := time.Now().Add(-5 * time.Minute)
|
||||||
|
na := nb.Add(time.Duration(ro.Days) * 24 * time.Hour)
|
||||||
|
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: ro.CN,
|
||||||
|
Organization: []string{ro.Org},
|
||||||
|
OrganizationalUnit: []string{"SAML Signing"},
|
||||||
|
},
|
||||||
|
NotBefore: nb,
|
||||||
|
NotAfter: na,
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var der []byte
|
||||||
|
var keyPKCS8 []byte
|
||||||
|
|
||||||
|
switch strings.ToLower(ro.Algo) {
|
||||||
|
case "rsa2048", "rsa3072", "rsa4096":
|
||||||
|
bits := 2048
|
||||||
|
if ro.Algo == "rsa3072" {
|
||||||
|
bits = 3072
|
||||||
|
}
|
||||||
|
if ro.Algo == "rsa4096" {
|
||||||
|
bits = 4096
|
||||||
|
}
|
||||||
|
priv, e := rsa.GenerateKey(rand.Reader, bits)
|
||||||
|
if e != nil {
|
||||||
|
return nil, nil, time.Time{}, e
|
||||||
|
}
|
||||||
|
der, err = x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
keyPKCS8, err = x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
case "p256", "p384":
|
||||||
|
curve := elliptic.P256()
|
||||||
|
if ro.Algo == "p384" {
|
||||||
|
curve = elliptic.P384()
|
||||||
|
}
|
||||||
|
priv, e := ecdsa.GenerateKey(curve, rand.Reader)
|
||||||
|
if e != nil {
|
||||||
|
return nil, nil, time.Time{}, e
|
||||||
|
}
|
||||||
|
der, err = x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
keyPKCS8, err = x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, time.Time{}, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, nil, time.Time{}, fmt.Errorf("unknown -algo %q", ro.Algo)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyPKCS8})
|
||||||
|
return certPEM, keyPEM, na, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig(path string) (*config.Config, *yaml.Node, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var root yaml.Node
|
||||||
|
if err := yaml.Unmarshal(b, &root); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var c config.Config
|
||||||
|
if err := root.Decode(&c); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return &c, &root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfig(path string, _ *yaml.Node, c *config.Config) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := yaml.NewEncoder(&buf)
|
||||||
|
enc.SetIndent(2)
|
||||||
|
if err := enc.Encode(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = enc.Close()
|
||||||
|
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func k8sSecretYAML(ro rotateOpts, certPEM, keyPEM []byte) string {
|
||||||
|
name := strings.ToLower(strings.ReplaceAll(ro.ID, "_", "-"))
|
||||||
|
return fmt.Sprintf(`apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: saml-signing-%s
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
cert.pem: |-
|
||||||
|
%s
|
||||||
|
key.pem: |-
|
||||||
|
%s
|
||||||
|
`, name, indent(string(certPEM), 4), indent(string(keyPEM), 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(s string, n int) string {
|
||||||
|
pad := strings.Repeat(" ", n)
|
||||||
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = pad + lines[i]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
36
internal/config/config.go
Normal file
36
internal/config/config.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var c Config
|
||||||
|
if err := yaml.Unmarshal(b, &c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Validate() error {
|
||||||
|
if c.Server.ExternalURL == "" || c.Server.Listen == "" {
|
||||||
|
return fmt.Errorf("server.external_url and server.listen required")
|
||||||
|
}
|
||||||
|
if len(c.SPs) == 0 {
|
||||||
|
return fmt.Errorf("at least one SP required")
|
||||||
|
}
|
||||||
|
if c.OIDC.Issuer == "" || c.OIDC.ClientID == "" || c.OIDC.RedirectPath == "" {
|
||||||
|
return fmt.Errorf("oidc issuer/client_id/redirect_path required")
|
||||||
|
}
|
||||||
|
if c.Crypto.ActiveKey == "" || len(c.Crypto.Keys) == 0 {
|
||||||
|
return fmt.Errorf("crypto.active_key and at least one key required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
68
internal/config/types.go
Normal file
68
internal/config/types.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Listen string `yaml:"listen"`
|
||||||
|
ExternalURL string `yaml:"external_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPair struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
CertPEM string `yaml:"cert_pem"`
|
||||||
|
KeyPEM string `yaml:"key_pem"`
|
||||||
|
NotAfter time.Time `yaml:"not_after"`
|
||||||
|
}
|
||||||
|
type Crypto struct {
|
||||||
|
ActiveKey string `yaml:"active_key"`
|
||||||
|
Keys []KeyPair `yaml:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OIDC struct {
|
||||||
|
Issuer string `yaml:"issuer"`
|
||||||
|
ClientID string `yaml:"client_id"`
|
||||||
|
ClientSecret string `yaml:"-"`
|
||||||
|
RedirectPath string `yaml:"redirect_path"`
|
||||||
|
Scopes []string `yaml:"scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SP struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
EntityID string `yaml:"entity_id"`
|
||||||
|
ACSURL string `yaml:"acs_url"`
|
||||||
|
Audience string `yaml:"audience"`
|
||||||
|
NameIDFormat string `yaml:"nameid_format"`
|
||||||
|
AttributeMapping map[string]string `yaml:"attribute_mapping"`
|
||||||
|
RoleMapping map[string]string `yaml:"role_mapping"`
|
||||||
|
AttributeRules []AttributeRule `yaml:"attribute_rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Security struct {
|
||||||
|
SkewSeconds int `yaml:"skew_seconds"`
|
||||||
|
AssertionTTLSec int `yaml:"assertion_ttl_seconds"`
|
||||||
|
RequireSignedAuthnRequest bool `yaml:"require_signed_authn_request"`
|
||||||
|
MetadataValidUntilDays int `yaml:"metadata_valid_until_days"`
|
||||||
|
MetadataCacheDurationSeconds int `yaml:"metadata_cache_duration_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
CookieName string `yaml:"cookie_name"`
|
||||||
|
CookieSecure bool `yaml:"cookie_secure"`
|
||||||
|
CookieDomain string `yaml:"cookie_domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server Server `yaml:"server"`
|
||||||
|
Crypto Crypto `yaml:"crypto"`
|
||||||
|
OIDC OIDC `yaml:"oidc_upstream"`
|
||||||
|
SPs []SP `yaml:"sps"`
|
||||||
|
Security Security `yaml:"security"`
|
||||||
|
Session Session `yaml:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttributeRule struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
IfGroupsAny []string `yaml:"if_groups_any"`
|
||||||
|
EmitWhenFalse bool `yaml:"emit_when_false"`
|
||||||
|
}
|
||||||
74
internal/crypto/keystore.go
Normal file
74
internal/crypto/keystore.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KeyStore struct {
|
||||||
|
activeID string
|
||||||
|
signers map[string]tls.Certificate
|
||||||
|
certsDER map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKeyStore(c config.Crypto) (*KeyStore, error) {
|
||||||
|
ks := &KeyStore{
|
||||||
|
activeID: c.ActiveKey,
|
||||||
|
signers: map[string]tls.Certificate{},
|
||||||
|
certsDER: map[string][]byte{},
|
||||||
|
}
|
||||||
|
for _, k := range c.Keys {
|
||||||
|
cert, priv, err := parseKeypair(k.CertPEM, k.KeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("key %s: %w", k.ID, err)
|
||||||
|
}
|
||||||
|
ks.certsDER[k.ID] = cert.Raw
|
||||||
|
if priv != nil {
|
||||||
|
ks.signers[k.ID] = tls.Certificate{Certificate: [][]byte{cert.Raw}, PrivateKey: priv}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := ks.signers[ks.activeID]; !ok {
|
||||||
|
return nil, errors.New("active signing key not available (missing or no private key)")
|
||||||
|
}
|
||||||
|
return ks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ks *KeyStore) Active() tls.Certificate { return ks.signers[ks.activeID] }
|
||||||
|
func (ks *KeyStore) AllCertsDER() [][]byte {
|
||||||
|
out := make([][]byte, 0, len(ks.certsDER))
|
||||||
|
for _, der := range ks.certsDER {
|
||||||
|
out = append(out, der)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKeypair(certPEM, keyPEM string) (*x509.Certificate, interface{}, error) {
|
||||||
|
cb, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if cb == nil {
|
||||||
|
return nil, nil, errors.New("invalid cert pem")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(cb.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
var priv interface{}
|
||||||
|
if keyPEM != "" {
|
||||||
|
kb, _ := pem.Decode([]byte(keyPEM))
|
||||||
|
if kb == nil {
|
||||||
|
return nil, nil, errors.New("invalid key pem")
|
||||||
|
}
|
||||||
|
priv, err = x509.ParsePKCS8PrivateKey(kb.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
priv, err = x509.ParsePKCS1PrivateKey(kb.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cert, priv, nil
|
||||||
|
}
|
||||||
261
internal/http/handlers.go
Normal file
261
internal/http/handlers.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/flate"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/oidc"
|
||||||
|
idsaml "shamilnunhuck/saml-oidc-bridge/internal/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IdP interface {
|
||||||
|
Metadata() *saml.EntityDescriptor
|
||||||
|
BuildResponse(sp config.SP, nameID string, attrs map[string][]string) (*saml.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Register(
|
||||||
|
mux *http.ServeMux,
|
||||||
|
getCfg func() *config.Config,
|
||||||
|
getIdP func() *idsaml.IdP,
|
||||||
|
getOIDC func() *oidc.Client,
|
||||||
|
) {
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/saml/metadata", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
meta := getIdP().Metadata()
|
||||||
|
buf, err := xml.MarshalIndent(meta, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "marshal metadata: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/samlmetadata+xml")
|
||||||
|
_, _ = w.Write([]byte(xml.Header))
|
||||||
|
_, _ = w.Write(buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/saml/sso", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
req, spEntityID, relay, err := parseAuthnRequest(r)
|
||||||
|
log.Printf("AuthnRequest from SP=%s requestID=%s relay=%q", spEntityID, req.ID, relay)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad authn request: "+err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStateCookie(w, getCfg().Session, spEntityID, relay, req.ID)
|
||||||
|
state := randomState()
|
||||||
|
http.Redirect(w, r, getOIDC().AuthCodeURL(state, url.Values{}), http.StatusFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc(getCfg().OIDC.RedirectPath, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := context.Background()
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
http.Error(w, "missing code", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := getOIDC().ExchangeAndVerify(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "oidc: "+err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := readStateCookie(r, getCfg().Session)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "state missing", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := lookupSP(getCfg(), s.SPEntityID)
|
||||||
|
if sp == nil {
|
||||||
|
http.Error(w, "unknown SP", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := map[string][]string{}
|
||||||
|
for samlAttr, oidcClaim := range sp.AttributeMapping {
|
||||||
|
switch oidcClaim {
|
||||||
|
case "email":
|
||||||
|
attrs[samlAttr] = []string{claims.Email}
|
||||||
|
case "name":
|
||||||
|
attrs[samlAttr] = []string{claims.Name}
|
||||||
|
case "role":
|
||||||
|
attrs[samlAttr] = []string{mapRole(claims.Groups, sp)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nameID := claims.Email
|
||||||
|
groups := claims.Groups
|
||||||
|
|
||||||
|
// Apply generic conditional attribute rules
|
||||||
|
userGroups := toSet(groups) // []string -> set
|
||||||
|
for _, rule := range sp.AttributeRules {
|
||||||
|
if hasAnyGroup(userGroups, rule.IfGroupsAny) {
|
||||||
|
if rule.Value == "" { // safe default
|
||||||
|
attrs[rule.Name] = []string{"true"}
|
||||||
|
} else {
|
||||||
|
attrs[rule.Name] = []string{rule.Value}
|
||||||
|
}
|
||||||
|
} else if rule.EmitWhenFalse {
|
||||||
|
attrs[rule.Name] = []string{"false"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := getIdP().BuildResponse(*sp, nameID, attrs, s.RequestID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "saml: "+err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlBytes, err := idsaml.MarshalSignedResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "sign: "+err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postToACS(w, sp.ACSURL, base64.StdEncoding.EncodeToString(xmlBytes), s.RelayState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** helpers ***/
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
SPEntityID string
|
||||||
|
RelayState string
|
||||||
|
RequestID string
|
||||||
|
Expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStateCookie(w http.ResponseWriter, s config.Session, spEntityID, relay, reqID string) {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("sp", spEntityID)
|
||||||
|
v.Set("rs", relay)
|
||||||
|
v.Set("rid", reqID)
|
||||||
|
c := &http.Cookie{
|
||||||
|
Name: s.CookieName,
|
||||||
|
Value: base64.RawURLEncoding.EncodeToString([]byte(v.Encode())),
|
||||||
|
Path: "/",
|
||||||
|
Domain: s.CookieDomain,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.CookieSecure,
|
||||||
|
MaxAge: 600,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readStateCookie(r *http.Request, s config.Session) (*state, error) {
|
||||||
|
c, err := r.Cookie(s.CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := base64.RawURLEncoding.DecodeString(c.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := url.ParseQuery(string(b))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &state{
|
||||||
|
SPEntityID: v.Get("sp"),
|
||||||
|
RelayState: v.Get("rs"),
|
||||||
|
RequestID: v.Get("rid"),
|
||||||
|
Expiry: time.Now().Add(10 * time.Minute),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupSP(cfg *config.Config, entityID string) *config.SP {
|
||||||
|
for i := range cfg.SPs {
|
||||||
|
if cfg.SPs[i].EntityID == entityID {
|
||||||
|
return &cfg.SPs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRole(groups []string, sp *config.SP) string {
|
||||||
|
for _, g := range groups {
|
||||||
|
if v, ok := sp.RoleMapping[g]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := sp.RoleMapping["*"]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
func postToACS(w http.ResponseWriter, acsURL string, samlResponseB64 string, relay string) {
|
||||||
|
const tpl = `<!doctype html>
|
||||||
|
<html><body onload="document.forms[0].submit()">
|
||||||
|
<form method="post" action="{{.ACS}}">
|
||||||
|
<input type="hidden" name="SAMLResponse" value="{{.Resp}}">
|
||||||
|
{{if .Relay}}<input type="hidden" name="RelayState" value="{{.Relay}}">{{end}}
|
||||||
|
<noscript><button type="submit">Continue</button></noscript>
|
||||||
|
</form></body></html>`
|
||||||
|
t := template.Must(template.New("post").Parse(tpl))
|
||||||
|
_ = t.Execute(w, map[string]string{"ACS": acsURL, "Resp": samlResponseB64, "Relay": relay})
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomState() string {
|
||||||
|
var b [16]byte
|
||||||
|
_, _ = rand.Read(b[:])
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuthnRequest(r *http.Request) (*saml.AuthnRequest, string, string, error) {
|
||||||
|
relay := r.FormValue("RelayState")
|
||||||
|
if sr := r.URL.Query().Get("SAMLRequest"); sr != "" {
|
||||||
|
xmlBytes, err := base64.StdEncoding.DecodeString(sr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("b64: %w", err)
|
||||||
|
}
|
||||||
|
reader := flate.NewReader(strings.NewReader(string(xmlBytes)))
|
||||||
|
defer reader.Close()
|
||||||
|
var sb strings.Builder
|
||||||
|
if _, err := io.Copy(&sb, reader); err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("inflate: %w", err)
|
||||||
|
}
|
||||||
|
var req saml.AuthnRequest
|
||||||
|
if err := xml.Unmarshal([]byte(sb.String()), &req); err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("xml: %w", err)
|
||||||
|
}
|
||||||
|
sp := ""
|
||||||
|
if req.Issuer != nil {
|
||||||
|
sp = req.Issuer.Value
|
||||||
|
}
|
||||||
|
return &req, sp, relay, nil
|
||||||
|
}
|
||||||
|
if sr := r.FormValue("SAMLRequest"); sr != "" {
|
||||||
|
xmlBytes, err := base64.StdEncoding.DecodeString(sr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("b64: %w", err)
|
||||||
|
}
|
||||||
|
var req saml.AuthnRequest
|
||||||
|
if err := xml.Unmarshal(xmlBytes, &req); err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("xml: %w", err)
|
||||||
|
}
|
||||||
|
sp := ""
|
||||||
|
if req.Issuer != nil {
|
||||||
|
sp = req.Issuer.Value
|
||||||
|
}
|
||||||
|
return &req, sp, relay, nil
|
||||||
|
}
|
||||||
|
return nil, "", "", fmt.Errorf("missing SAMLRequest")
|
||||||
|
}
|
||||||
21
internal/http/util.go
Normal file
21
internal/http/util.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
func hasAnyGroup(user map[string]struct{}, want []string) bool {
|
||||||
|
if len(want) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, g := range want {
|
||||||
|
if _, ok := user[g]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSet(ss []string) map[string]struct{} {
|
||||||
|
m := make(map[string]struct{}, len(ss))
|
||||||
|
for _, s := range ss {
|
||||||
|
m[s] = struct{}{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
70
internal/oidc/client.go
Normal file
70
internal/oidc/client.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Verifier *gooidc.IDTokenVerifier
|
||||||
|
OAuth2 *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(cfg *config.Config) (*Client, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
provider, err := gooidc.NewProvider(ctx, cfg.OIDC.Issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
verifier := provider.Verifier(&gooidc.Config{ClientID: cfg.OIDC.ClientID})
|
||||||
|
redirect := cfg.Server.ExternalURL + cfg.OIDC.RedirectPath
|
||||||
|
|
||||||
|
scopes := []string{"openid"}
|
||||||
|
scopes = append(scopes, cfg.OIDC.Scopes...)
|
||||||
|
|
||||||
|
oauth2cfg := &oauth2.Config{
|
||||||
|
ClientID: cfg.OIDC.ClientID,
|
||||||
|
ClientSecret: cfg.OIDC.ClientSecret,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
Scopes: scopes,
|
||||||
|
RedirectURL: redirect,
|
||||||
|
}
|
||||||
|
return &Client{Verifier: verifier, OAuth2: oauth2cfg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) AuthCodeURL(state string, extra url.Values) string {
|
||||||
|
return c.OAuth2.AuthCodeURL(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ExchangeAndVerify(ctx context.Context, code string) (*Claims, error) {
|
||||||
|
token, err := c.OAuth2.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rawID, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no id_token in token response")
|
||||||
|
}
|
||||||
|
idt, err := c.Verifier.Verify(ctx, rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cl Claims
|
||||||
|
if err := idt.Claims(&cl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cl, nil
|
||||||
|
}
|
||||||
70
internal/saml/idp.go
Normal file
70
internal/saml/idp.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/crypto"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IdP struct {
|
||||||
|
keys *crypto.KeyStore
|
||||||
|
sec config.Security
|
||||||
|
entityID string
|
||||||
|
ssoURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIdP(cfg *config.Config, ks *crypto.KeyStore) *IdP {
|
||||||
|
return &IdP{
|
||||||
|
keys: ks,
|
||||||
|
sec: cfg.Security,
|
||||||
|
entityID: cfg.Server.ExternalURL,
|
||||||
|
ssoURL: cfg.Server.ExternalURL + "/saml/sso",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IdP) Metadata() *saml.EntityDescriptor {
|
||||||
|
// we need to publish all certs, to allow safe rotation
|
||||||
|
keyDescriptors := []saml.KeyDescriptor{}
|
||||||
|
|
||||||
|
for _, der := range i.keys.AllCertsDER() {
|
||||||
|
keyDescriptors = append(keyDescriptors, saml.KeyDescriptor{
|
||||||
|
Use: "signing",
|
||||||
|
KeyInfo: saml.KeyInfo{
|
||||||
|
X509Data: saml.X509Data{
|
||||||
|
X509Certificates: []saml.X509Certificate{{
|
||||||
|
Data: base64.StdEncoding.EncodeToString(der),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
entityDescriptor := &saml.EntityDescriptor{
|
||||||
|
EntityID: i.entityID,
|
||||||
|
// crewjam expects time.Time for ValidUntil and time.Duration for CacheDuration
|
||||||
|
ValidUntil: time.Now().UTC().Add(time.Duration(i.sec.MetadataValidUntilDays) * 24 * time.Hour),
|
||||||
|
CacheDuration: time.Duration(i.sec.MetadataCacheDurationSeconds) * time.Second,
|
||||||
|
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
||||||
|
{
|
||||||
|
SSODescriptor: saml.SSODescriptor{
|
||||||
|
RoleDescriptor: saml.RoleDescriptor{
|
||||||
|
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
||||||
|
KeyDescriptors: keyDescriptors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SingleSignOnServices: []saml.Endpoint{
|
||||||
|
{
|
||||||
|
Binding: saml.HTTPPostBinding,
|
||||||
|
Location: i.ssoURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return entityDescriptor
|
||||||
|
}
|
||||||
204
internal/saml/sign.go
Normal file
204
internal/saml/sign.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beevik/etree"
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
dsig "github.com/russellhaering/goxmldsig"
|
||||||
|
|
||||||
|
"shamilnunhuck/saml-oidc-bridge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subjectConfirmationMethodBearer = "urn:oasis:names:tc:SAML:2.0:cm:bearer"
|
||||||
|
nameIDFormatEntity = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity"
|
||||||
|
authnContextPasswordProtectedTransport = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
|
||||||
|
defaultAssertionTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *IdP) BuildResponse(sp config.SP, nameID string, attrs map[string][]string, inResponseTo string) (*saml.Response, error) {
|
||||||
|
now := saml.TimeNow()
|
||||||
|
ttl := time.Duration(i.sec.AssertionTTLSec) * time.Second
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultAssertionTTL
|
||||||
|
}
|
||||||
|
skew := time.Duration(i.sec.SkewSeconds) * time.Second
|
||||||
|
|
||||||
|
assertionID, err := newSAMLID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate assertion id: %w", err)
|
||||||
|
}
|
||||||
|
responseID, err := newSAMLID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate response id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audience := sp.Audience
|
||||||
|
if audience == "" {
|
||||||
|
audience = sp.EntityID
|
||||||
|
}
|
||||||
|
|
||||||
|
nameIDFormat := sp.NameIDFormat
|
||||||
|
if nameIDFormat == "" {
|
||||||
|
nameIDFormat = string(saml.UnspecifiedNameIDFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion := &saml.Assertion{
|
||||||
|
ID: assertionID,
|
||||||
|
IssueInstant: now,
|
||||||
|
Version: "2.0",
|
||||||
|
Issuer: saml.Issuer{
|
||||||
|
Format: nameIDFormatEntity,
|
||||||
|
Value: i.entityID,
|
||||||
|
},
|
||||||
|
Subject: &saml.Subject{
|
||||||
|
NameID: &saml.NameID{
|
||||||
|
Format: nameIDFormat,
|
||||||
|
Value: nameID,
|
||||||
|
},
|
||||||
|
SubjectConfirmations: []saml.SubjectConfirmation{
|
||||||
|
{
|
||||||
|
Method: subjectConfirmationMethodBearer,
|
||||||
|
SubjectConfirmationData: &saml.SubjectConfirmationData{
|
||||||
|
InResponseTo: inResponseTo,
|
||||||
|
NotOnOrAfter: now.Add(ttl),
|
||||||
|
Recipient: sp.ACSURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Conditions: &saml.Conditions{
|
||||||
|
NotBefore: now.Add(-skew),
|
||||||
|
NotOnOrAfter: now.Add(ttl),
|
||||||
|
AudienceRestrictions: []saml.AudienceRestriction{
|
||||||
|
{
|
||||||
|
Audience: saml.Audience{Value: audience},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthnStatements: []saml.AuthnStatement{
|
||||||
|
{
|
||||||
|
AuthnInstant: now,
|
||||||
|
SessionIndex: responseID,
|
||||||
|
AuthnContext: saml.AuthnContext{
|
||||||
|
AuthnContextClassRef: &saml.AuthnContextClassRef{Value: authnContextPasswordProtectedTransport},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AttributeStatements: []saml.AttributeStatement{
|
||||||
|
{
|
||||||
|
Attributes: toSAMLAttributes(attrs),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &saml.Response{
|
||||||
|
ID: responseID,
|
||||||
|
Version: "2.0",
|
||||||
|
IssueInstant: now,
|
||||||
|
InResponseTo: inResponseTo,
|
||||||
|
Destination: sp.ACSURL,
|
||||||
|
Issuer: &saml.Issuer{
|
||||||
|
Format: nameIDFormatEntity,
|
||||||
|
Value: i.entityID,
|
||||||
|
},
|
||||||
|
Status: saml.Status{
|
||||||
|
StatusCode: saml.StatusCode{Value: saml.StatusSuccess},
|
||||||
|
},
|
||||||
|
Assertion: assertion,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := i.signResponse(resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IdP) signingContext() (*dsig.SigningContext, error) {
|
||||||
|
keyPair := i.keys.Active()
|
||||||
|
if len(keyPair.Certificate) == 0 || keyPair.PrivateKey == nil {
|
||||||
|
return nil, errors.New("active key missing certificate or private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := dsig.NewDefaultSigningContext(dsig.TLSCertKeyStore(keyPair))
|
||||||
|
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("")
|
||||||
|
if err := ctx.SetSignatureMethod(dsig.RSASHA256SignatureMethod); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx.Hash = crypto.SHA256
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IdP) signResponse(resp *saml.Response) error {
|
||||||
|
if resp.Assertion == nil {
|
||||||
|
return errors.New("response missing assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertionCtx, err := i.signingContext()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
assertionEl := resp.Assertion.Element()
|
||||||
|
signedAssertionEl, err := assertionCtx.SignEnveloped(assertionEl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign assertion: %w", err)
|
||||||
|
}
|
||||||
|
sigEl, err := lastChildElement(signedAssertionEl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign assertion: %w", err)
|
||||||
|
}
|
||||||
|
resp.Assertion.Signature = sigEl
|
||||||
|
|
||||||
|
responseCtx, err := i.signingContext()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
responseEl := resp.Element()
|
||||||
|
signedResponseEl, err := responseCtx.SignEnveloped(responseEl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign response: %w", err)
|
||||||
|
}
|
||||||
|
sigEl, err = lastChildElement(signedResponseEl)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sign response: %w", err)
|
||||||
|
}
|
||||||
|
resp.Signature = sigEl
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalSignedResponse(resp *saml.Response) ([]byte, error) {
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("nil response")
|
||||||
|
}
|
||||||
|
if resp.Signature == nil {
|
||||||
|
return nil, errors.New("response not signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := etree.NewDocument()
|
||||||
|
doc.SetRoot(resp.Element())
|
||||||
|
return doc.WriteToBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastChildElement(parent *etree.Element) (*etree.Element, error) {
|
||||||
|
children := parent.ChildElements()
|
||||||
|
if len(children) == 0 {
|
||||||
|
return nil, errors.New("no child elements found")
|
||||||
|
}
|
||||||
|
return children[len(children)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSAMLID() (string, error) {
|
||||||
|
var b [20]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "_" + hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
26
internal/saml/util.go
Normal file
26
internal/saml/util.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import "github.com/crewjam/saml"
|
||||||
|
|
||||||
|
const attrNameFormatURI = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri"
|
||||||
|
|
||||||
|
func toSAMLAttributes(attrs map[string][]string) []saml.Attribute {
|
||||||
|
out := make([]saml.Attribute, 0, len(attrs))
|
||||||
|
for name, vals := range attrs {
|
||||||
|
vs := make([]saml.AttributeValue, 0, len(vals))
|
||||||
|
for _, s := range vals {
|
||||||
|
vs = append(vs, saml.AttributeValue{
|
||||||
|
// explicitly mark string type so we never emit xsi:type=""
|
||||||
|
Type: "xs:string",
|
||||||
|
Value: s,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, saml.Attribute{
|
||||||
|
FriendlyName: name,
|
||||||
|
Name: name,
|
||||||
|
NameFormat: attrNameFormatURI,
|
||||||
|
Values: vs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user