diff --git a/go.mod b/go.mod index 94cd288c648e0..24c2a87588485 100644 --- a/go.mod +++ b/go.mod @@ -167,6 +167,7 @@ require ( github.com/okta/okta-sdk-golang/v2 v2.20.0 github.com/opencontainers/go-digest v1.0.0 github.com/opensearch-project/opensearch-go/v2 v2.3.0 + github.com/oracle/oci-go-sdk/v65 v65.81.0 github.com/parquet-go/parquet-go v0.24.0 github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 @@ -505,6 +506,7 @@ require ( github.com/sigstore/rekor v1.3.6 // indirect github.com/sigstore/timestamp-authority v1.2.2 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sony/gobreaker v0.5.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect diff --git a/go.sum b/go.sum index de4ade8412e83..b3745494ac076 100644 --- a/go.sum +++ b/go.sum @@ -1353,6 +1353,7 @@ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= @@ -1961,6 +1962,8 @@ github.com/opensearch-project/opensearch-go/v2 v2.3.0 h1:nQIEMr+A92CkhHrZgUhcfsr github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/oracle/oci-go-sdk/v65 v65.81.0 h1:uyAdy7N7q3cj090zrLCCL+IbL3JHd4IXZi2N5epXeAk= +github.com/oracle/oci-go-sdk/v65 v65.81.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= github.com/parquet-go/parquet-go v0.24.0 h1:VrsifmLPDnas8zpoHmYiWDZ1YHzLmc7NmNwPGkI2JM4= github.com/parquet-go/parquet-go v0.24.0/go.mod h1:OqBBRGBl7+llplCvDMql8dEKaDqjaFA/VAPw+OJiNiw= github.com/pascaldekloe/name v1.0.1 h1:9lnXOHeqeHHnWLbKfH6X98+4+ETVqFqxN09UXSjcMb0= @@ -2158,6 +2161,8 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/snowflakedb/gosnowflake v1.13.0 h1:NQoy4mnHUmBuruJhzAGVRO9YLpFxayYTCLf+dxvG7bk= github.com/snowflakedb/gosnowflake v1.13.0/go.mod h1:nwiPNHaS3EGxnW1rr10ascVYFLA4EKrqMX2TxPt0+N4= +github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= +github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= diff --git a/integrations/event-handler/go.sum b/integrations/event-handler/go.sum index fc152a6203026..3f0f35e46bbe0 100644 --- a/integrations/event-handler/go.sum +++ b/integrations/event-handler/go.sum @@ -668,6 +668,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/oracle/oci-go-sdk/v65 v65.81.0 h1:uyAdy7N7q3cj090zrLCCL+IbL3JHd4IXZi2N5epXeAk= +github.com/oracle/oci-go-sdk/v65 v65.81.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible h1:IWzUvJ72xMjmrjR9q3H1PF+jwdN0uNQiR2t1BLNalyo= github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ= @@ -766,6 +768,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= +github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index 4e3cda04f5ade..29c833f9f934c 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -283,6 +283,7 @@ require ( github.com/onsi/ginkgo/v2 v2.21.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/oracle/oci-go-sdk/v65 v65.81.0 // indirect github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible // indirect github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect @@ -313,6 +314,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sijms/go-ora/v2 v2.8.23 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sony/gobreaker v0.5.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index f13d27b478b92..a22557038bb65 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -549,6 +549,7 @@ github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus= github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= @@ -1016,6 +1017,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opensearch-project/opensearch-go/v2 v2.3.0 h1:nQIEMr+A92CkhHrZgUhcfsrZjibvB3APXf2a1VwCmMQ= github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8= +github.com/oracle/oci-go-sdk/v65 v65.81.0 h1:uyAdy7N7q3cj090zrLCCL+IbL3JHd4IXZi2N5epXeAk= +github.com/oracle/oci-go-sdk/v65 v65.81.0/go.mod h1:IBEV9l1qBzUpo7zgGaRUhbB05BVfcDGYRFBCPlTcPp0= github.com/parquet-go/parquet-go v0.24.0 h1:VrsifmLPDnas8zpoHmYiWDZ1YHzLmc7NmNwPGkI2JM4= github.com/parquet-go/parquet-go v0.24.0/go.mod h1:OqBBRGBl7+llplCvDMql8dEKaDqjaFA/VAPw+OJiNiw= github.com/pascaldekloe/name v1.0.1 h1:9lnXOHeqeHHnWLbKfH6X98+4+ETVqFqxN09UXSjcMb0= @@ -1141,6 +1144,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= +github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= @@ -1153,6 +1158,7 @@ github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GB github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -1164,6 +1170,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= diff --git a/lib/auth/join/join.go b/lib/auth/join/join.go index 940cd3b211720..7d1648becc4d0 100644 --- a/lib/auth/join/join.go +++ b/lib/auth/join/join.go @@ -21,6 +21,7 @@ import ( "crypto" "crypto/x509" "log/slog" + "net/http" "os" "time" @@ -40,6 +41,7 @@ import ( "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/auth/join/iam" + "github.com/gravitational/teleport/lib/auth/join/oracle" "github.com/gravitational/teleport/lib/auth/state" "github.com/gravitational/teleport/lib/bitbucket" "github.com/gravitational/teleport/lib/circleci" @@ -811,10 +813,31 @@ func registerUsingTPMMethod( return certs, trace.Wrap(err) } +func mapFromHeader(header http.Header) map[string]string { + out := make(map[string]string, len(header)) + for k := range header { + out[k] = header.Get(k) + } + return out +} + func registerUsingOracleMethod( ctx context.Context, client joinServiceClient, token string, hostKeys *newHostKeys, params RegisterParams, ) (*proto.Certs, error) { - return nil, trace.NotImplemented("Not implemented") + certs, err := client.RegisterUsingOracleMethod( + ctx, + registerUsingTokenRequestForParams(token, hostKeys, params), + func(challenge string) (*proto.OracleSignedRequest, error) { + innerHeaders, outerHeaders, err := oracle.CreateSignedRequest(challenge) + if err != nil { + return nil, trace.Wrap(err) + } + return &proto.OracleSignedRequest{ + Headers: mapFromHeader(outerHeaders), + PayloadHeaders: mapFromHeader(innerHeaders), + }, nil + }) + return certs, trace.Wrap(err) } // readCA will read in CA that will be used to validate the certificate that diff --git a/lib/auth/join/oracle/oracle.go b/lib/auth/join/oracle/oracle.go new file mode 100644 index 0000000000000..0a037d6b108f1 --- /dev/null +++ b/lib/auth/join/oracle/oracle.go @@ -0,0 +1,322 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package oracle + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/common/auth" + + "github.com/gravitational/teleport/api" + "github.com/gravitational/teleport/lib/defaults" +) + +const teleportUserAgent = "teleport/" + api.Version + +const ( + // DateHeader is the header containing the date to send to Oracle. + DateHeader = "x-date" + // ChallengeHeader is the header containing the Teleport-generated challenge + // string to send to Oracle. + ChallengeHeader = "x-teleport-challenge" +) + +const ( + tenancyClaim = "opc-tenant" + compartmentClaim = "opc-compartment" + instanceClaim = "opc-instance" +) + +type authenticateClientDetails struct { + RequestHeaders http.Header `json:"requestHeaders"` +} + +type authenticateClientRequest struct { + Date string `contributesTo:"header" name:"x-date"` + Challenge string `contributesTo:"header" name:"x-teleport-challenge"` + UserAgent string `contributesTo:"header" name:"User-Agent"` + Details authenticateClientDetails `contributesTo:"body"` +} + +// Claims are the claims returned by the authenticateClient endpoint. +type Claims struct { + // TenancyID is the ID of the instance's tenant. + TenancyID string `json:"tenant_id"` + // CompartmentID is the ID of the instance's compartment. + CompartmentID string `json:"compartment_id"` + // InstanceID is the instance's ID. + InstanceID string `json:"-"` +} + +// Region extracts the region from an instance's claims. +func (c Claims) Region() string { + region, err := ParseRegionFromOCID(c.InstanceID) + if err != nil { + return "" + } + return region +} + +type claim struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type principal struct { + Claims []claim `json:"claims"` +} + +func (p principal) getClaims() Claims { + var claims Claims + for _, claim := range p.Claims { + switch claim.Key { + case tenancyClaim: + claims.TenancyID = claim.Value + case compartmentClaim: + claims.CompartmentID = claim.Value + case instanceClaim: + claims.InstanceID = claim.Value + } + } + return claims +} + +type authenticateClientResult struct { + ErrorMessage string `json:"errorMessage,omitempty"` + Principal principal `json:"principal,omitempty"` +} + +type authenticateClientResponse struct { + Result authenticateClientResult `presentIn:"body"` +} + +func newAuthenticateClientRequest(time time.Time, challenge string, headers http.Header) authenticateClientRequest { + req := authenticateClientRequest{ + Date: time.UTC().Format(http.TimeFormat), + Challenge: challenge, + UserAgent: teleportUserAgent, + Details: authenticateClientDetails{ + RequestHeaders: headers, + }, + } + // Avoid a null request body. + if len(headers) == 0 { + req.Details.RequestHeaders = http.Header{} + } + return req +} + +func createAuthHTTPRequest(region string, auth authenticateClientRequest) (*http.Request, error) { + req, err := common.MakeDefaultHTTPRequestWithTaggedStruct( + http.MethodPost, + "", + auth, + ) + if err != nil { + return nil, trace.Wrap(err) + } + endpointURL, err := url.Parse(fmt.Sprintf("https://auth.%s.oraclecloud.com/v1/authentication/authenticateClient", region)) + if err != nil { + return nil, trace.Wrap(err) + } + // Manually set the host header so it will be sent as part of the grpc. + req.Header.Set("Host", endpointURL.Host) + req.Host = endpointURL.Host + req.URL = endpointURL + + // If no headers were provided, this is the inner header payload and we need + // to explicitly include (request-target). + if len(auth.Details.RequestHeaders) == 0 { + req.Header.Set("(request-target)", strings.ToLower(http.MethodPost)+" "+endpointURL.RequestURI()) + } + return &req, nil +} + +// CreateSignedRequest creates a signed HTTP request to +// https://auth..oraclecloud.com/v1/authentication/authenticateClient. +// The returned headers should be sent to an auth server as part of +// RegisterUsingOracleMethod. +func CreateSignedRequest(challenge string) (innerHeaders, outerHeaders http.Header, err error) { + provider, err := auth.InstancePrincipalConfigurationProvider() + if err != nil { + return nil, nil, trace.Wrap(err) + } + inner, outer, err := createSignedRequest(provider, challenge) + return inner, outer, trace.Wrap(err) +} + +func createSignedRequest(provider common.ConfigurationProvider, challenge string) (innerHeaders, outerHeaders http.Header, err error) { + signedHeaders := append(common.DefaultGenericHeaders(), DateHeader, ChallengeHeader) + signer := common.RequestSigner(provider, signedHeaders, common.DefaultBodyHeaders()) + region, err := provider.Region() + if err != nil { + return nil, nil, trace.Wrap(err) + } + now := time.Now().UTC() + innerReq, err := createAuthHTTPRequest(region, newAuthenticateClientRequest(now, challenge, nil)) + if err != nil { + return nil, nil, trace.Wrap(err) + } + signer.Sign(innerReq) + + outerReq, err := createAuthHTTPRequest(region, newAuthenticateClientRequest(now, challenge, innerReq.Header)) + if err != nil { + return nil, nil, trace.Wrap(err) + } + signer.Sign(outerReq) + return innerReq.Header, outerReq.Header, nil +} + +// GetAuthorizationHeaderValues gets the key-value pairs encoded in the +// Authorization header as described in the [Oracle API docs]. +// +// [Oracle API docs]: https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm#five +func GetAuthorizationHeaderValues(header http.Header) map[string]string { + rawValues, isSignature := strings.CutPrefix(header.Get("Authorization"), "Signature ") + if !isSignature { + return nil + } + keyValuePairs := strings.Split(rawValues, ",") + values := make(map[string]string, len(keyValuePairs)) + for _, pair := range keyValuePairs { + k, v, isPair := strings.Cut(pair, "=") + if !isPair { + continue + } + values[k] = strings.Trim(v, "\"") + } + return values +} + +// CreateRequestFromHeaders recreates an HTTP request to the authenticateClient +// endpoint from its inner and outer headers. +func CreateRequestFromHeaders(region string, innerHeaders, outerHeaders http.Header) (*http.Request, error) { + req, err := createAuthHTTPRequest(region, authenticateClientRequest{ + Date: outerHeaders.Get(DateHeader), + Challenge: outerHeaders.Get(ChallengeHeader), + UserAgent: teleportUserAgent, + Details: authenticateClientDetails{ + RequestHeaders: innerHeaders, + }, + }) + if err != nil { + return nil, trace.Wrap(err) + } + req.Header = outerHeaders + return req, nil +} + +// FetchOraclePrincipalClaims executes a request to authenticateClient and parses +// the response. +func FetchOraclePrincipalClaims(ctx context.Context, req *http.Request) (Claims, error) { + client, err := defaults.HTTPClient() + if err != nil { + return Claims{}, trace.Wrap(err) + } + // Block redirects. + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + authResp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return Claims{}, trace.Wrap(err) + } + defer authResp.Body.Close() + var resp authenticateClientResponse + unmarshalErr := common.UnmarshalResponse(authResp, &resp) + if authResp.StatusCode >= 300 || resp.Result.ErrorMessage != "" { + msg := resp.Result.ErrorMessage + if msg == "" { + msg = authResp.Status + } + return Claims{}, trace.AccessDenied("%v", msg) + } + if unmarshalErr != nil { + return Claims{}, trace.Wrap(unmarshalErr) + } + return resp.Result.Principal.getClaims(), nil +} + +// Hack: StringToRegion will lazily load regions from a config file if its +// input isn't in its hard-coded list, in a non-threadsafe way. Call it here +// to load the config ahead of time so future calls are threadsafe. +var _ = common.StringToRegion("") + +// ParseRegion parses a string into a full (not abbreviated) Oracle Cloud +// region. It returns the empty string if the input is not a valid region. +func ParseRegion(rawRegion string) (region, realm string) { + canonicalRegion := common.StringToRegion(rawRegion) + realm, err := canonicalRegion.RealmID() + if err != nil { + return "", "" + } + return string(canonicalRegion), realm +} + +var ociRealms = map[string]struct{}{ + "oc1": {}, "oc2": {}, "oc3": {}, "oc4": {}, "oc8": {}, "oc9": {}, + "oc10": {}, "oc14": {}, "oc15": {}, "oc19": {}, "oc20": {}, "oc21": {}, + "oc23": {}, "oc24": {}, "oc26": {}, "oc29": {}, "oc35": {}, +} + +// ParseRegionFromOCID parses an Oracle OCID and returns the embedded region. +// It returns an error if the input is not a valid OCID. +func ParseRegionFromOCID(ocid string) (string, error) { + // OCID format: ocid1...[REGION][.FUTURE USE]. + // Check format. + ocidParts := strings.Split(ocid, ".") + switch len(ocidParts) { + case 5, 6: + default: + return "", trace.BadParameter("not an ocid") + } + // Check version. + if ocidParts[0] != "ocid1" { + return "", trace.BadParameter("invalid ocid version: %v", ocidParts[0]) + } + // Check realm. + if _, ok := ociRealms[ocidParts[2]]; !ok { + return "", trace.BadParameter("invalid realm: %v", ocidParts[2]) + } + resourceType := ocidParts[1] + region, realm := ParseRegion(ocidParts[3]) + // Check type. Only instance OCIDs should have a region. + switch resourceType { + case "instance": + if region == "" { + return "", trace.BadParameter("invalid region: %v", region) + } + if realm != ocidParts[2] { + return "", trace.BadParameter("invalid realm %q for region %q", ocidParts[2], region) + } + case "compartment", "tenancy": + if ocidParts[3] != "" { + return "", trace.BadParameter("resource type %v should not have a region", resourceType) + } + default: + return "", trace.BadParameter("unsupported resource type: %v", resourceType) + } + return region, nil +} diff --git a/lib/auth/join/oracle/oracle_test.go b/lib/auth/join/oracle/oracle_test.go new file mode 100644 index 0000000000000..03cdf64015f80 --- /dev/null +++ b/lib/auth/join/oracle/oracle_test.go @@ -0,0 +1,324 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package oracle + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/fixtures" +) + +func TestGetAuthorizationHeaderValues(t *testing.T) { + t.Parallel() + tests := []struct { + name string + authHeader string + expectedValues map[string]string + }{ + { + name: "ok", + authHeader: "Signature foo=bar,baz=\"quux\"", + expectedValues: map[string]string{ + "foo": "bar", + "baz": "quux", + }, + }, + { + name: "invalid key-value format", + authHeader: "Signature foo:bar,baz", + expectedValues: map[string]string{}, + }, + { + name: "wrong authorization type", + authHeader: "Bearer foo=bar", + expectedValues: nil, + }, + { + name: "missing auth header", + authHeader: "", + expectedValues: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + header := http.Header{} + header.Set("Authorization", tc.authHeader) + require.Equal(t, tc.expectedValues, GetAuthorizationHeaderValues(header)) + }) + } +} + +func TestCreateSignedRequest(t *testing.T) { + t.Parallel() + + pemBytes, ok := fixtures.PEMBytes["rsa"] + require.True(t, ok) + + provider := common.NewRawConfigurationProvider( + "ocid1.tenancy.oc1..abcd1234", + "ocid1.user.oc1..abcd1234", + "us-ashburn-1", + "fingerprint", + string(pemBytes), + nil, + ) + + innerHeader, outerHeader, err := createSignedRequest(provider, "challenge") + require.NoError(t, err) + + expectedHeaders := map[string]string{ + "Accept": "*/*", + "Authorization": "", + "Content-Length": "", + "Content-Type": "application/json", + "Host": "auth.us-ashburn-1.oraclecloud.com", + "User-Agent": teleportUserAgent, + "X-Content-Sha256": "", + DateHeader: "", + ChallengeHeader: "challenge", + } + expectedAuthHeader := map[string]string{ + "version": "1", + "headers": "date (request-target) host x-date x-teleport-challenge content-length content-type x-content-sha256", + "keyId": "ocid1.tenancy.oc1..abcd1234/ocid1.user.oc1..abcd1234/fingerprint", + "algorithm": "rsa-sha256", + "signature": "", + } + + for _, header := range []http.Header{innerHeader, outerHeader} { + for k, v := range expectedHeaders { + if v == "" { + assert.NotEmpty(t, header.Get(k), "missing header: %s", k) + } else { + assert.Equal(t, v, header.Get(k)) + } + } + authValues := GetAuthorizationHeaderValues(header) + for k, v := range expectedAuthHeader { + if v == "" { + assert.NotEmpty(t, authValues[k], "missing auth header value: %s", k) + } else { + assert.Equal(t, v, authValues[k]) + } + } + } +} + +func TestFetchOraclePrincipalClaims(t *testing.T) { + t.Parallel() + + defaultTenancyID := "tenancy-id" + defaultCompartmentID := "compartment-id" + defaultInstanceID := "instance-id" + + defaultHandler := func(code int, responseBody any) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + body, err := json.Marshal(responseBody) + assert.NoError(t, err) + _, err = w.Write(body) + assert.NoError(t, err) + }) + } + + tests := []struct { + name string + handler http.Handler + assert assert.ErrorAssertionFunc + expectedClaims Claims + }{ + { + name: "ok", + handler: defaultHandler(http.StatusOK, authenticateClientResult{ + Principal: principal{ + Claims: []claim{ + { + Key: tenancyClaim, + Value: defaultTenancyID, + }, + { + Key: compartmentClaim, + Value: defaultCompartmentID, + }, + { + Key: instanceClaim, + Value: defaultInstanceID, + }, + }, + }, + }), + assert: assert.NoError, + expectedClaims: Claims{ + TenancyID: defaultTenancyID, + CompartmentID: defaultCompartmentID, + InstanceID: defaultInstanceID, + }, + }, + { + name: "block redirect", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NotEqual(t, "/dontgohere", r.RequestURI, "redirect was incorrectly performed") + http.Redirect(w, r, "/dontgohere", http.StatusFound) + }), + assert: assert.Error, + }, + { + name: "http error", + handler: defaultHandler(http.StatusNotFound, authenticateClientResult{}), + assert: assert.Error, + }, + { + name: "api error", + handler: defaultHandler(http.StatusOK, authenticateClientResult{ + ErrorMessage: "it didn't work", + }), + assert: assert.Error, + }, + { + name: "invalid response type", + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("some junk")) + }), + assert: assert.Error, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(tc.handler) + t.Cleanup(srv.Close) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + req, err := http.NewRequest("", srv.URL, nil) + require.NoError(t, err) + claims, err := FetchOraclePrincipalClaims(ctx, req) + tc.assert(t, err) + assert.Equal(t, tc.expectedClaims, claims) + }) + } +} + +func TestParseRegion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputRegion string + expectedRegion string + expectedRealm string + }{ + { + name: "valid full region", + inputRegion: "us-phoenix-1", + expectedRegion: "us-phoenix-1", + expectedRealm: "oc1", + }, + { + name: "valid abbreviated region", + inputRegion: "iad", + expectedRegion: "us-ashburn-1", + expectedRealm: "oc1", + }, + { + name: "invalid region", + inputRegion: "foo", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + region, realm := ParseRegion(tc.inputRegion) + assert.Equal(t, tc.expectedRegion, region) + assert.Equal(t, tc.expectedRealm, realm) + }) + } +} + +func TestParseRegionFromOCID(t *testing.T) { + t.Parallel() + tests := []struct { + name string + ocid string + assert assert.ErrorAssertionFunc + expectedRegion string + }{ + { + name: "ok", + ocid: "ocid1.instance.oc1.us-phoenix-1.abcd1234", + assert: assert.NoError, + expectedRegion: "us-phoenix-1", + }, + { + name: "ok with future use", + ocid: "ocid1.instance.oc1.us-phoenix-1.FUTURE.abcd1234", + assert: assert.NoError, + expectedRegion: "us-phoenix-1", + }, + { + name: "ok with compartment/tenancy", + ocid: "ocid1.compartment.oc1..abcd1234", + assert: assert.NoError, + }, + { + name: "not an ocid", + ocid: "some.junk", + assert: assert.Error, + }, + { + name: "invalid version", + ocid: "ocid2.instance.oc1.us-phoenix-1.abcd1234", + assert: assert.Error, + }, + { + name: "missing region on instance", + ocid: "ocid1.instance.oc1..abcd1234", + assert: assert.Error, + }, + { + name: "unexpected region on compartment/tenancy", + ocid: "ocid1.tenancy.oc1.us-phoenix-1.abcd1234", + assert: assert.Error, + }, + { + name: "invalid realm", + ocid: "ocid1.instance.ocxyz.us-phoenix-1.abcd1234", + assert: assert.Error, + }, + { + name: "invalid region", + ocid: "ocid1.instance.oc1.junk-region.abcd1234", + assert: assert.Error, + }, + { + name: "invalid region for realm", + ocid: "ocid1.instance.oc2.us-phoenix-1.abcd1234", + assert: assert.Error, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + region, err := ParseRegionFromOCID(tc.ocid) + tc.assert(t, err) + assert.Equal(t, tc.expectedRegion, region) + }) + } +}