Files
ziti/controller/oidc_auth/parse.go
Andrew Martinez 949de99ee4 fixes #3809 support CSR submission during OIDC authentication (#3840)
* fixes #3809 support CSR submission during OIDC authentication

- accepts an optional CSR during OIDC login (all auth methods) and
  signs it into a session-bound certificate with a SPIFFE ID derived
  from the identity and API session
- returns the signed certificate PEM as a top-level "session_cert"
  field in the token endpoint JSON response (CodeExchange, RefreshToken,
  TokenExchange)
- adds cert-binding verification on RefreshToken and TokenExchange:
  if z_cfs is present the peer cert fingerprint must match (strict),
  otherwise falls back to SPIFFE ID verification
- supports cert rotation via csr_pem form parameter on refresh and
  token exchange; replaces the session cert fingerprint while
  preserving the authenticating cert fingerprint
- adds AuthCertFingerprints (z_acfs) claim to track permanent auth
  cert fingerprints separately from rotatable session cert fingerprints
- only the leaf certificate fingerprint is added to z_cfs and z_acfs,
  intermediates are never included
- invalid CSR returns 400 Bad Request in OIDC error format
- propagates updated CustomClaims (including CertFingerprints) from
  access token to renewed refresh token so rotated fingerprints are
  enforced on subsequent refreshes
- adds CertGenerated field to ApiSessionEvent
- advertises OIDC_AUTH_WITH_CSR controller capability when OIDC is
  enabled
- adds unit tests for verifyCertBinding (fingerprint, SPIFFE ID,
  edge cases) and CsrPem field parsing
- adds integration tests for initial CSR auth (updb, cert, ext-jwt),
  cert-binding on refresh/exchange, CSR rotation with cert auth,
  SPIFFE fallback and z_cfs transition, and CSR property forging
  resistance

* address pr concerns

* add z_cfs len tests on junk chain certs

* strip csr subject info, replace w/ santized values

* fix csr rotation rejection/paths during token exchange/refresh
2026-05-07 13:56:48 -04:00

255 lines
5.5 KiB
Go

/*
Copyright NetFoundry Inc.
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
https://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.
*/
package oidc_auth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
"unicode/utf8"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/foundation/v2/errorz"
)
type TotpRequestBody struct {
AuthRequestBody
Code string `json:"code"`
}
type OidcUpdbCreds struct {
rest_model.Authenticate
AuthRequestBody
CsrPem string `json:"csrPem"`
}
func (u *OidcUpdbCreds) Translate(in string, paths ...string) (string, bool) {
if len(paths) > 0 {
last := paths[len(paths)-1:][0]
switch last {
case "EnvInfo":
return "env" + upperCaseInitial(in), true
case "SdkInfo":
return "sdk" + upperCaseInitial(in), true
}
}
return "", false
}
type AuthRequestBody struct {
AuthRequestId string `json:"id"`
}
func (a *AuthRequestBody) SetAuthRequestId(id string) {
a.AuthRequestId = id
}
func (a *AuthRequestBody) GetAuthRequestId() string {
return a.AuthRequestId
}
var _ AuthRequestIdHolder = (*AuthRequestBody)(nil)
type AuthRequestIdHolder interface {
SetAuthRequestId(string)
GetAuthRequestId() string
}
type FieldTranslator interface {
Translate(string, ...string) (string, bool)
}
func MapToStruct(m map[string][]string, dst interface{}) error {
translator, _ := dst.(FieldTranslator)
return mapToStruct(0, m, dst, translator)
}
func mapToStruct(depth int, src map[string][]string, dst interface{}, translator FieldTranslator, paths ...string) error {
if paths == nil {
paths = []string{}
}
rv := reflect.ValueOf(dst)
if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Struct {
return fmt.Errorf("expected a pointer to a struct")
}
rv = rv.Elem()
rt := rv.Type()
for i := 0; i < rv.NumField(); i++ {
field := rv.Field(i)
fieldType := rt.Field(i)
fieldName := fieldType.Name
tagValue := fieldType.Tag.Get("json")
tagVals := strings.Split(tagValue, ",")
if len(tagVals) == 0 {
tagVals = []string{""}
}
if tagVals[0] == "-" {
continue
}
if field.Kind() == reflect.Struct {
fieldPtr := field.Addr().Interface()
newPaths := make([]string, len(paths))
copy(newPaths, paths)
if depth > 0 {
newPaths = append(newPaths, fieldName)
}
err := mapToStruct(depth+1, src, fieldPtr, translator, newPaths...)
if err != nil {
return err
}
continue
} else if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct {
var fieldPtr interface{}
newPaths := make([]string, len(paths))
copy(newPaths, paths)
if depth > 0 {
newPaths = append(newPaths, fieldName)
}
if !field.IsNil() {
fieldPtr = field.Interface()
} else {
// Initialize the nil pointer to a new struct before proceeding
newStruct := reflect.New(field.Type().Elem())
field.Set(newStruct)
fieldPtr = newStruct.Interface()
}
err := mapToStruct(depth+1, src, fieldPtr, translator, newPaths...)
if err != nil {
return err
}
continue
}
var mapValue []string
var ok bool
if tagVals[0] != "" {
if translator != nil {
translation, translatedOk := translator.Translate(tagVals[0], paths...)
if translatedOk {
mapValue, ok = src[translation]
} else {
mapValue, ok = src[tagVals[0]]
}
} else {
mapValue, ok = src[tagVals[0]]
}
}
if !ok {
mapValue, ok = src[fieldType.Name]
}
if !ok || len(mapValue) == 0 {
continue
}
switch field.Kind() {
case reflect.String:
if len(mapValue) > 0 {
field.SetString(mapValue[0])
}
case reflect.Slice:
if fieldType.Type.Elem().Kind() == reflect.String {
field.Set(reflect.ValueOf(mapValue))
}
default:
panic("unhandled default case")
}
}
return nil
}
func parsePayload(r *http.Request, out AuthRequestIdHolder) error {
contentType, err := negotiateBodyContentType(r)
if err != nil {
return err
}
if contentType == FormContentType {
err := r.ParseForm()
if err != nil {
return fmt.Errorf("cannot parse form: %s", err)
}
err = MapToStruct(r.Form, out)
if err != nil {
return err
}
} else if contentType == JsonContentType {
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, out)
if err != nil {
return err
}
} else {
return &errorz.ApiError{
AppCode: "UNSUPPORTED_MEDIA_TYPE",
Message: fmt.Sprintf("the content type: %s, is not supported (supported: %s, %s)", contentType, FormContentType, JsonContentType),
Status: http.StatusUnsupportedMediaType,
Cause: nil,
AppendCause: false,
}
}
//prefer body, if not set use > query string queryAuthRequestID > query string queryAuthRequestIdAlt
if out.GetAuthRequestId() == "" {
if queryAuthRequestId := r.URL.Query().Get(queryAuthRequestID); queryAuthRequestId != "" {
out.SetAuthRequestId(queryAuthRequestId)
} else if queryId := r.URL.Query().Get(queryAuthRequestIdAlt); queryId != "" {
out.SetAuthRequestId(queryId)
}
}
return nil
}
func upperCaseInitial(in string) string {
if in != "" {
r, size := utf8.DecodeRuneInString(in)
return strings.ToUpper(string(r)) + in[size:]
}
return ""
}