diff --git a/apis/concierge/config/v1alpha1/types_credentialissuer.go.tmpl b/apis/concierge/config/v1alpha1/types_credentialissuer.go.tmpl
index 60edeccc..49966390 100644
--- a/apis/concierge/config/v1alpha1/types_credentialissuer.go.tmpl
+++ b/apis/concierge/config/v1alpha1/types_credentialissuer.go.tmpl
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/cmd/pinniped/cmd/kubeconfig.go b/cmd/pinniped/cmd/kubeconfig.go
index 32d724a2..54042b34 100644
--- a/cmd/pinniped/cmd/kubeconfig.go
+++ b/cmd/pinniped/cmd/kubeconfig.go
@@ -61,6 +61,7 @@ type getKubeconfigOIDCParams struct {
listenPort uint16
scopes []string
skipBrowser bool
+ skipListen bool
sessionCachePath string
debugSessionCache bool
caBundle caBundleFlag
@@ -146,6 +147,7 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
f.Uint16Var(&flags.oidc.listenPort, "oidc-listen-port", 0, "TCP port for localhost listener (authorization code flow only)")
f.StringSliceVar(&flags.oidc.scopes, "oidc-scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OpenID Connect scopes to request during login")
f.BoolVar(&flags.oidc.skipBrowser, "oidc-skip-browser", false, "During OpenID Connect login, skip opening the browser (just print the URL)")
+ f.BoolVar(&flags.oidc.skipListen, "oidc-skip-listen", false, "During OpenID Connect login, skip starting a localhost callback listener (manual copy/paste flow only)")
f.StringVar(&flags.oidc.sessionCachePath, "oidc-session-cache", "", "Path to OpenID Connect session cache file")
f.Var(&flags.oidc.caBundle, "oidc-ca-bundle", "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)")
f.BoolVar(&flags.oidc.debugSessionCache, "oidc-debug-session-cache", false, "Print debug logs related to the OpenID Connect session cache")
@@ -161,6 +163,9 @@ func kubeconfigCommand(deps kubeconfigDeps) *cobra.Command {
f.StringVar(&flags.credentialCachePath, "credential-cache", "", "Path to cluster-specific credentials cache")
mustMarkHidden(cmd, "oidc-debug-session-cache")
+ // --oidc-skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case.
+ mustMarkHidden(cmd, "oidc-skip-listen")
+
mustMarkDeprecated(cmd, "concierge-namespace", "not needed anymore")
mustMarkHidden(cmd, "concierge-namespace")
@@ -317,6 +322,9 @@ func newExecConfig(deps kubeconfigDeps, flags getKubeconfigParams) (*clientcmdap
if flags.oidc.skipBrowser {
execConfig.Args = append(execConfig.Args, "--skip-browser")
}
+ if flags.oidc.skipListen {
+ execConfig.Args = append(execConfig.Args, "--skip-listen")
+ }
if flags.oidc.listenPort != 0 {
execConfig.Args = append(execConfig.Args, "--listen-port="+strconv.Itoa(int(flags.oidc.listenPort)))
}
diff --git a/cmd/pinniped/cmd/kubeconfig_test.go b/cmd/pinniped/cmd/kubeconfig_test.go
index 2853b0e4..cb9f7b83 100644
--- a/cmd/pinniped/cmd/kubeconfig_test.go
+++ b/cmd/pinniped/cmd/kubeconfig_test.go
@@ -1352,6 +1352,7 @@ func TestGetKubeconfig(t *testing.T) {
"--concierge-ca-bundle", testConciergeCABundlePath,
"--oidc-issuer", issuerURL,
"--oidc-skip-browser",
+ "--oidc-skip-listen",
"--oidc-listen-port", "1234",
"--oidc-ca-bundle", f.Name(),
"--oidc-session-cache", "/path/to/cache/dir/sessions.yaml",
@@ -1405,6 +1406,7 @@ func TestGetKubeconfig(t *testing.T) {
- --client-id=pinniped-cli
- --scopes=offline_access,openid,pinniped:request-audience
- --skip-browser
+ - --skip-listen
- --listen-port=1234
- --ca-bundle-data=%s
- --session-cache=/path/to/cache/dir/sessions.yaml
diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go
index 83542c01..9141d26a 100644
--- a/cmd/pinniped/cmd/login_oidc.go
+++ b/cmd/pinniped/cmd/login_oidc.go
@@ -59,6 +59,7 @@ type oidcLoginFlags struct {
listenPort uint16
scopes []string
skipBrowser bool
+ skipListen bool
sessionCachePath string
caBundlePaths []string
caBundleData []string
@@ -91,6 +92,7 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
cmd.Flags().Uint16Var(&flags.listenPort, "listen-port", 0, "TCP port for localhost listener (authorization code flow only)")
cmd.Flags().StringSliceVar(&flags.scopes, "scopes", []string{oidc.ScopeOfflineAccess, oidc.ScopeOpenID, "pinniped:request-audience"}, "OIDC scopes to request during login")
cmd.Flags().BoolVar(&flags.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL)")
+ cmd.Flags().BoolVar(&flags.skipListen, "skip-listen", false, "Skip starting a localhost callback listener (manual copy/paste flow only)")
cmd.Flags().StringVar(&flags.sessionCachePath, "session-cache", filepath.Join(mustGetConfigDir(), "sessions.yaml"), "Path to session cache file")
cmd.Flags().StringSliceVar(&flags.caBundlePaths, "ca-bundle", nil, "Path to TLS certificate authority bundle (PEM format, optional, can be repeated)")
cmd.Flags().StringSliceVar(&flags.caBundleData, "ca-bundle-data", nil, "Base64 encoded TLS certificate authority bundle (base64 encoded PEM format, optional, can be repeated)")
@@ -107,6 +109,8 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
cmd.Flags().StringVar(&flags.upstreamIdentityProviderName, "upstream-identity-provider-name", "", "The name of the upstream identity provider used during login with a Supervisor")
cmd.Flags().StringVar(&flags.upstreamIdentityProviderType, "upstream-identity-provider-type", "oidc", "The type of the upstream identity provider used during login with a Supervisor (e.g. 'oidc', 'ldap')")
+ // --skip-listen is mainly needed for testing. We'll leave it hidden until we have a non-testing use case.
+ mustMarkHidden(cmd, "skip-listen")
mustMarkHidden(cmd, "debug-session-cache")
mustMarkRequired(cmd, "issuer")
cmd.RunE = func(cmd *cobra.Command, args []string) error { return runOIDCLogin(cmd, deps, flags) }
@@ -182,12 +186,14 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
}
}
- // --skip-browser replaces the default "browser open" function with one that prints to stderr.
+ // --skip-browser skips opening the browser.
if flags.skipBrowser {
- opts = append(opts, oidcclient.WithBrowserOpen(func(url string) error {
- cmd.PrintErr("Please log in: ", url, "\n")
- return nil
- }))
+ opts = append(opts, oidcclient.WithSkipBrowserOpen())
+ }
+
+ // --skip-listen skips starting the localhost callback listener.
+ if flags.skipListen {
+ opts = append(opts, oidcclient.WithSkipListen())
}
if len(flags.caBundlePaths) > 0 || len(flags.caBundleData) > 0 {
diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go
index b31d7021..055dcec6 100644
--- a/cmd/pinniped/cmd/login_oidc_test.go
+++ b/cmd/pinniped/cmd/login_oidc_test.go
@@ -226,6 +226,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--client-id", "test-client-id",
"--issuer", "test-issuer",
"--skip-browser",
+ "--skip-listen",
"--listen-port", "1234",
"--debug-session-cache",
"--request-audience", "cluster-1234",
@@ -242,7 +243,7 @@ func TestLoginOIDCCommand(t *testing.T) {
"--upstream-identity-provider-type", "ldap",
},
env: map[string]string{"PINNIPED_DEBUG": "true"},
- wantOptionsCount: 10,
+ wantOptionsCount: 11,
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n",
wantLogs: []string{
"\"level\"=0 \"msg\"=\"Pinniped login: Performing OIDC login\" \"client id\"=\"test-client-id\" \"issuer\"=\"test-issuer\"",
diff --git a/deploy/concierge/config.concierge.pinniped.dev_credentialissuers.yaml b/deploy/concierge/config.concierge.pinniped.dev_credentialissuers.yaml
index 7b94b098..4014551f 100644
--- a/deploy/concierge/config.concierge.pinniped.dev_credentialissuers.yaml
+++ b/deploy/concierge/config.concierge.pinniped.dev_credentialissuers.yaml
@@ -47,7 +47,7 @@ spec:
description: "ExternalEndpoint describes the HTTPS endpoint where
the proxy will be exposed. If not set, the proxy will be served
using the external name of the LoadBalancer service or the cluster
- service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.mode
+ service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.type
is \"None\"."
type: string
mode:
diff --git a/generated/1.17/README.adoc b/generated/1.17/README.adoc
index e9637cbb..cb311448 100644
--- a/generated/1.17/README.adoc
+++ b/generated/1.17/README.adoc
@@ -411,7 +411,7 @@ ImpersonationProxyServiceSpec describes how the Concierge should provision a Ser
| *`mode`* __ImpersonationProxyMode__ | Mode configures whether the impersonation proxy should be started: - "disabled" explicitly disables the impersonation proxy. This is the default. - "enabled" explicitly enables the impersonation proxy. - "auto" enables or disables the impersonation proxy based upon the cluster in which it is running.
| *`service`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-17-apis-concierge-config-v1alpha1-impersonationproxyservicespec[$$ImpersonationProxyServiceSpec$$]__ | Service describes the configuration of the Service provisioned to expose the impersonation proxy to clients.
| *`externalEndpoint`* __string__ | ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will be served using the external name of the LoadBalancer service or the cluster service DNS name.
- This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ This field must be non-empty when spec.impersonationProxy.service.type is "None".
|===
diff --git a/generated/1.17/apis/concierge/config/v1alpha1/types_credentialissuer.go b/generated/1.17/apis/concierge/config/v1alpha1/types_credentialissuer.go
index 60edeccc..49966390 100644
--- a/generated/1.17/apis/concierge/config/v1alpha1/types_credentialissuer.go
+++ b/generated/1.17/apis/concierge/config/v1alpha1/types_credentialissuer.go
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/generated/1.17/crds/config.concierge.pinniped.dev_credentialissuers.yaml b/generated/1.17/crds/config.concierge.pinniped.dev_credentialissuers.yaml
index 7b94b098..4014551f 100644
--- a/generated/1.17/crds/config.concierge.pinniped.dev_credentialissuers.yaml
+++ b/generated/1.17/crds/config.concierge.pinniped.dev_credentialissuers.yaml
@@ -47,7 +47,7 @@ spec:
description: "ExternalEndpoint describes the HTTPS endpoint where
the proxy will be exposed. If not set, the proxy will be served
using the external name of the LoadBalancer service or the cluster
- service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.mode
+ service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.type
is \"None\"."
type: string
mode:
diff --git a/generated/1.18/README.adoc b/generated/1.18/README.adoc
index 3fbd9d46..ade6ea1b 100644
--- a/generated/1.18/README.adoc
+++ b/generated/1.18/README.adoc
@@ -411,7 +411,7 @@ ImpersonationProxyServiceSpec describes how the Concierge should provision a Ser
| *`mode`* __ImpersonationProxyMode__ | Mode configures whether the impersonation proxy should be started: - "disabled" explicitly disables the impersonation proxy. This is the default. - "enabled" explicitly enables the impersonation proxy. - "auto" enables or disables the impersonation proxy based upon the cluster in which it is running.
| *`service`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-18-apis-concierge-config-v1alpha1-impersonationproxyservicespec[$$ImpersonationProxyServiceSpec$$]__ | Service describes the configuration of the Service provisioned to expose the impersonation proxy to clients.
| *`externalEndpoint`* __string__ | ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will be served using the external name of the LoadBalancer service or the cluster service DNS name.
- This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ This field must be non-empty when spec.impersonationProxy.service.type is "None".
|===
diff --git a/generated/1.18/apis/concierge/config/v1alpha1/types_credentialissuer.go b/generated/1.18/apis/concierge/config/v1alpha1/types_credentialissuer.go
index 60edeccc..49966390 100644
--- a/generated/1.18/apis/concierge/config/v1alpha1/types_credentialissuer.go
+++ b/generated/1.18/apis/concierge/config/v1alpha1/types_credentialissuer.go
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/generated/1.18/crds/config.concierge.pinniped.dev_credentialissuers.yaml b/generated/1.18/crds/config.concierge.pinniped.dev_credentialissuers.yaml
index 7b94b098..4014551f 100644
--- a/generated/1.18/crds/config.concierge.pinniped.dev_credentialissuers.yaml
+++ b/generated/1.18/crds/config.concierge.pinniped.dev_credentialissuers.yaml
@@ -47,7 +47,7 @@ spec:
description: "ExternalEndpoint describes the HTTPS endpoint where
the proxy will be exposed. If not set, the proxy will be served
using the external name of the LoadBalancer service or the cluster
- service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.mode
+ service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.type
is \"None\"."
type: string
mode:
diff --git a/generated/1.19/README.adoc b/generated/1.19/README.adoc
index 48e0b834..7f47bdeb 100644
--- a/generated/1.19/README.adoc
+++ b/generated/1.19/README.adoc
@@ -411,7 +411,7 @@ ImpersonationProxyServiceSpec describes how the Concierge should provision a Ser
| *`mode`* __ImpersonationProxyMode__ | Mode configures whether the impersonation proxy should be started: - "disabled" explicitly disables the impersonation proxy. This is the default. - "enabled" explicitly enables the impersonation proxy. - "auto" enables or disables the impersonation proxy based upon the cluster in which it is running.
| *`service`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-19-apis-concierge-config-v1alpha1-impersonationproxyservicespec[$$ImpersonationProxyServiceSpec$$]__ | Service describes the configuration of the Service provisioned to expose the impersonation proxy to clients.
| *`externalEndpoint`* __string__ | ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will be served using the external name of the LoadBalancer service or the cluster service DNS name.
- This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ This field must be non-empty when spec.impersonationProxy.service.type is "None".
|===
diff --git a/generated/1.19/apis/concierge/config/v1alpha1/types_credentialissuer.go b/generated/1.19/apis/concierge/config/v1alpha1/types_credentialissuer.go
index 60edeccc..49966390 100644
--- a/generated/1.19/apis/concierge/config/v1alpha1/types_credentialissuer.go
+++ b/generated/1.19/apis/concierge/config/v1alpha1/types_credentialissuer.go
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/generated/1.19/crds/config.concierge.pinniped.dev_credentialissuers.yaml b/generated/1.19/crds/config.concierge.pinniped.dev_credentialissuers.yaml
index 7b94b098..4014551f 100644
--- a/generated/1.19/crds/config.concierge.pinniped.dev_credentialissuers.yaml
+++ b/generated/1.19/crds/config.concierge.pinniped.dev_credentialissuers.yaml
@@ -47,7 +47,7 @@ spec:
description: "ExternalEndpoint describes the HTTPS endpoint where
the proxy will be exposed. If not set, the proxy will be served
using the external name of the LoadBalancer service or the cluster
- service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.mode
+ service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.type
is \"None\"."
type: string
mode:
diff --git a/generated/1.20/README.adoc b/generated/1.20/README.adoc
index 4df9c3d1..1bcf7d08 100644
--- a/generated/1.20/README.adoc
+++ b/generated/1.20/README.adoc
@@ -411,7 +411,7 @@ ImpersonationProxyServiceSpec describes how the Concierge should provision a Ser
| *`mode`* __ImpersonationProxyMode__ | Mode configures whether the impersonation proxy should be started: - "disabled" explicitly disables the impersonation proxy. This is the default. - "enabled" explicitly enables the impersonation proxy. - "auto" enables or disables the impersonation proxy based upon the cluster in which it is running.
| *`service`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-20-apis-concierge-config-v1alpha1-impersonationproxyservicespec[$$ImpersonationProxyServiceSpec$$]__ | Service describes the configuration of the Service provisioned to expose the impersonation proxy to clients.
| *`externalEndpoint`* __string__ | ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will be served using the external name of the LoadBalancer service or the cluster service DNS name.
- This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ This field must be non-empty when spec.impersonationProxy.service.type is "None".
|===
diff --git a/generated/1.20/apis/concierge/config/v1alpha1/types_credentialissuer.go b/generated/1.20/apis/concierge/config/v1alpha1/types_credentialissuer.go
index 60edeccc..49966390 100644
--- a/generated/1.20/apis/concierge/config/v1alpha1/types_credentialissuer.go
+++ b/generated/1.20/apis/concierge/config/v1alpha1/types_credentialissuer.go
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/generated/1.20/crds/config.concierge.pinniped.dev_credentialissuers.yaml b/generated/1.20/crds/config.concierge.pinniped.dev_credentialissuers.yaml
index 7b94b098..4014551f 100644
--- a/generated/1.20/crds/config.concierge.pinniped.dev_credentialissuers.yaml
+++ b/generated/1.20/crds/config.concierge.pinniped.dev_credentialissuers.yaml
@@ -47,7 +47,7 @@ spec:
description: "ExternalEndpoint describes the HTTPS endpoint where
the proxy will be exposed. If not set, the proxy will be served
using the external name of the LoadBalancer service or the cluster
- service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.mode
+ service DNS name. \n This field must be non-empty when spec.impersonationProxy.service.type
is \"None\"."
type: string
mode:
diff --git a/generated/latest/apis/concierge/config/v1alpha1/types_credentialissuer.go b/generated/latest/apis/concierge/config/v1alpha1/types_credentialissuer.go
index 60edeccc..49966390 100644
--- a/generated/latest/apis/concierge/config/v1alpha1/types_credentialissuer.go
+++ b/generated/latest/apis/concierge/config/v1alpha1/types_credentialissuer.go
@@ -96,7 +96,7 @@ type ImpersonationProxySpec struct {
// ExternalEndpoint describes the HTTPS endpoint where the proxy will be exposed. If not set, the proxy will
// be served using the external name of the LoadBalancer service or the cluster service DNS name.
//
- // This field must be non-empty when spec.impersonationProxy.service.mode is "None".
+ // This field must be non-empty when spec.impersonationProxy.service.type is "None".
//
// +optional
ExternalEndpoint string `json:"externalEndpoint,omitempty"`
diff --git a/go.mod b/go.mod
index 2b1522e8..584c25a5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module go.pinniped.dev
-go 1.14
+go 1.16
require (
github.com/MakeNowJust/heredoc/v2 v2.0.1
@@ -26,6 +26,7 @@ require (
github.com/spf13/cobra v1.2.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
+ github.com/tdewolff/minify/v2 v2.9.19
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
diff --git a/go.sum b/go.sum
index a57507b9..6c545de3 100644
--- a/go.sum
+++ b/go.sum
@@ -118,6 +118,7 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -840,6 +841,7 @@ github.com/markbates/safe v1.0.0/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kN
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
github.com/markbates/sigtx v1.0.0/go.mod h1:QF1Hv6Ic6Ca6W+T+DL0Y/ypborFKyvUY9HmuCD4VeTc=
github.com/markbates/willie v1.0.9/go.mod h1:fsrFVWl91+gXpx/6dv715j7i11fYPfZ9ZGfH0DQzY7w=
+github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -1150,6 +1152,12 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/subosito/gotenv v1.1.1/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
+github.com/tdewolff/minify/v2 v2.9.19 h1:QdCQhIqCeBnSWU/4e0nJ8QJeQUNO5CC7jrH3xwETkVI=
+github.com/tdewolff/minify/v2 v2.9.19/go.mod h1:PoDBts2L7sCwUT28vTAlozGeD6qxjrrihtin4bR/RMM=
+github.com/tdewolff/parse/v2 v2.5.19 h1:Kjaj3KQOx/4elIxlBSglus4E2oMfdROphvbq2b+OBZ0=
+github.com/tdewolff/parse/v2 v2.5.19/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho=
+github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4=
+github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/gjson v1.6.8/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI=
github.com/tidwall/gjson v1.7.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk=
diff --git a/internal/httputil/securityheader/securityheader.go b/internal/httputil/securityheader/securityheader.go
index 2bb3af12..e95edd0b 100644
--- a/internal/httputil/securityheader/securityheader.go
+++ b/internal/httputil/securityheader/securityheader.go
@@ -1,16 +1,22 @@
-// Copyright 2020 the Pinniped contributors. All Rights Reserved.
+// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package securityheader implements an HTTP middleware for setting security-related response headers.
package securityheader
-import "net/http"
+import (
+ "net/http"
+)
// Wrap the provided http.Handler so it sets appropriate security-related response headers.
func Wrap(wrapped http.Handler) http.Handler {
+ return WrapWithCustomCSP(wrapped, "default-src 'none'; frame-ancestors 'none'")
+}
+
+func WrapWithCustomCSP(wrapped http.Handler, cspHeader string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
- h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
+ h.Set("Content-Security-Policy", cspHeader)
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "1; mode=block")
h.Set("X-Content-Type-Options", "nosniff")
diff --git a/internal/httputil/securityheader/securityheader_test.go b/internal/httputil/securityheader/securityheader_test.go
index 7ee7331f..639c495c 100644
--- a/internal/httputil/securityheader/securityheader_test.go
+++ b/internal/httputil/securityheader/securityheader_test.go
@@ -16,40 +16,71 @@ import (
)
func TestWrap(t *testing.T) {
- testServer := httptest.NewServer(Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Test-Header", "test value")
- _, _ = w.Write([]byte("hello world"))
- })))
- t.Cleanup(testServer.Close)
+ for _, tt := range []struct {
+ name string
+ wrapFunc func(http.Handler) http.Handler
+ expectHeaders http.Header
+ }{
+ {
+ name: "wrap",
+ wrapFunc: Wrap,
+ expectHeaders: http.Header{
+ "X-Test-Header": []string{"test value"},
+ "Content-Security-Policy": []string{"default-src 'none'; frame-ancestors 'none'"},
+ "Content-Type": []string{"text/plain; charset=utf-8"},
+ "Referrer-Policy": []string{"no-referrer"},
+ "X-Content-Type-Options": []string{"nosniff"},
+ "X-Frame-Options": []string{"DENY"},
+ "X-Xss-Protection": []string{"1; mode=block"},
+ "X-Dns-Prefetch-Control": []string{"off"},
+ "Cache-Control": []string{"no-cache,no-store,max-age=0,must-revalidate"},
+ "Pragma": []string{"no-cache"},
+ "Expires": []string{"0"},
+ },
+ },
+ {
+ name: "custom CSP",
+ wrapFunc: func(h http.Handler) http.Handler { return WrapWithCustomCSP(h, "my-custom-csp-header") },
+ expectHeaders: http.Header{
+ "X-Test-Header": []string{"test value"},
+ "Content-Security-Policy": []string{"my-custom-csp-header"},
+ "Content-Type": []string{"text/plain; charset=utf-8"},
+ "Referrer-Policy": []string{"no-referrer"},
+ "X-Content-Type-Options": []string{"nosniff"},
+ "X-Frame-Options": []string{"DENY"},
+ "X-Xss-Protection": []string{"1; mode=block"},
+ "X-Dns-Prefetch-Control": []string{"off"},
+ "Cache-Control": []string{"no-cache,no-store,max-age=0,must-revalidate"},
+ "Pragma": []string{"no-cache"},
+ "Expires": []string{"0"},
+ },
+ },
+ } {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ testServer := httptest.NewServer(tt.wrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Test-Header", "test value")
+ _, _ = w.Write([]byte("hello world"))
+ })))
+ t.Cleanup(testServer.Close)
- ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
- defer cancel()
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, testServer.URL, nil)
- require.NoError(t, err)
- resp, err := http.DefaultClient.Do(req)
- require.NoError(t, err)
- defer resp.Body.Close()
- require.Equal(t, http.StatusOK, resp.StatusCode)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, testServer.URL, nil)
+ require.NoError(t, err)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
- respBody, err := ioutil.ReadAll(resp.Body)
- require.NoError(t, err)
- require.Equal(t, "hello world", string(respBody))
+ respBody, err := ioutil.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, "hello world", string(respBody))
- expected := http.Header{
- "X-Test-Header": []string{"test value"},
- "Content-Security-Policy": []string{"default-src 'none'; frame-ancestors 'none'"},
- "Content-Type": []string{"text/plain; charset=utf-8"},
- "Referrer-Policy": []string{"no-referrer"},
- "X-Content-Type-Options": []string{"nosniff"},
- "X-Frame-Options": []string{"DENY"},
- "X-Xss-Protection": []string{"1; mode=block"},
- "X-Dns-Prefetch-Control": []string{"off"},
- "Cache-Control": []string{"no-cache,no-store,max-age=0,must-revalidate"},
- "Pragma": []string{"no-cache"},
- "Expires": []string{"0"},
- }
- for key, values := range expected {
- assert.Equalf(t, values, resp.Header.Values(key), "unexpected values for header %s", key)
+ for key, values := range tt.expectHeaders {
+ assert.Equalf(t, values, resp.Header.Values(key), "unexpected values for header %s", key)
+ }
+ })
}
}
diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go
index 9c92301e..e6917954 100644
--- a/internal/oidc/auth/auth_handler_test.go
+++ b/internal/oidc/auth/auth_handler_test.go
@@ -1156,7 +1156,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
require.Len(t, kubeClient.Actions(), test.wantUnnecessaryStoredRecords)
case test.wantRedirectLocationRegexp != "":
require.Len(t, rsp.Header().Values("Location"), 1)
- oidctestutil.RequireAuthcodeRedirectLocation(
+ oidctestutil.RequireAuthCodeRegexpMatch(
t,
rsp.Header().Get("Location"),
test.wantRedirectLocationRegexp,
diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go
index d585c962..8b9ab93e 100644
--- a/internal/oidc/callback/callback_handler.go
+++ b/internal/oidc/callback/callback_handler.go
@@ -18,6 +18,7 @@ import (
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/provider"
+ "go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/internal/plog"
)
@@ -35,7 +36,7 @@ func NewHandler(
stateDecoder, cookieDecoder oidc.Decoder,
redirectURI string,
) http.Handler {
- return securityheader.Wrap(httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
+ handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
state, err := validateRequest(r, stateDecoder, cookieDecoder)
if err != nil {
return err
@@ -97,7 +98,8 @@ func NewHandler(
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
return nil
- }))
+ })
+ return securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy())
}
func authcode(r *http.Request) string {
diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go
index 583ee943..23912944 100644
--- a/internal/oidc/callback/callback_handler_test.go
+++ b/internal/oidc/callback/callback_handler_test.go
@@ -122,6 +122,7 @@ func TestCallbackEndpoint(t *testing.T) {
wantContentType string
wantBody string
wantRedirectLocationRegexp string
+ wantBodyFormResponseRegexp string
wantDownstreamGrantedScopes []string
wantDownstreamIDTokenSubject string
wantDownstreamIDTokenUsername string
@@ -133,6 +134,32 @@ func TestCallbackEndpoint(t *testing.T) {
wantExchangeAndValidateTokensCall *oidctestutil.ExchangeAuthcodeAndValidateTokenArgs
}{
+ {
+ name: "GET with good state and cookie and successful upstream token exchange with response_mode=form_post returns 200 with HTML+JS form",
+ idp: happyUpstream().Build(),
+ method: http.MethodGet,
+ path: newRequestPath().WithState(
+ happyUpstreamStateParam().WithAuthorizeRequestParams(
+ shallowCopyAndModifyQuery(
+ happyDownstreamRequestParamsQuery,
+ map[string]string{"response_mode": "form_post"},
+ ).Encode(),
+ ).Build(t, happyStateCodec),
+ ).String(),
+ csrfCookie: happyCSRFCookie,
+ wantStatus: http.StatusOK,
+ wantContentType: "text/html;charset=UTF-8",
+ wantBodyFormResponseRegexp: `(.+)
`,
+ wantDownstreamIDTokenSubject: upstreamIssuer + "?sub=" + queryEscapedUpstreamSubject,
+ wantDownstreamIDTokenUsername: upstreamUsername,
+ wantDownstreamIDTokenGroups: upstreamGroupMembership,
+ wantDownstreamRequestedScopes: happyDownstreamScopesRequested,
+ wantDownstreamGrantedScopes: happyDownstreamScopesGranted,
+ wantDownstreamNonce: downstreamNonce,
+ wantDownstreamPKCEChallenge: downstreamPKCEChallenge,
+ wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod,
+ wantExchangeAndValidateTokensCall: happyExchangeAndValidateTokensArgs,
+ },
{
name: "GET with good state and cookie and successful upstream token exchange returns 302 to downstream client callback with its state and code",
idp: happyUpstream().Build(),
@@ -666,15 +693,40 @@ func TestCallbackEndpoint(t *testing.T) {
require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
- if test.wantBody != "" {
+ switch {
+ // If we want a specific static response body, assert that.
+ case test.wantBody != "":
require.Equal(t, test.wantBody, rsp.Body.String())
- } else {
+
+ // Else if we want a body that contains a regex-matched auth code, assert that (for "response_mode=form_post").
+ case test.wantBodyFormResponseRegexp != "":
+ oidctestutil.RequireAuthCodeRegexpMatch(
+ t,
+ rsp.Body.String(),
+ test.wantBodyFormResponseRegexp,
+ client,
+ secrets,
+ oauthStore,
+ test.wantDownstreamGrantedScopes,
+ test.wantDownstreamIDTokenSubject,
+ test.wantDownstreamIDTokenUsername,
+ test.wantDownstreamIDTokenGroups,
+ test.wantDownstreamRequestedScopes,
+ test.wantDownstreamPKCEChallenge,
+ test.wantDownstreamPKCEChallengeMethod,
+ test.wantDownstreamNonce,
+ downstreamClientID,
+ downstreamRedirectURI,
+ )
+
+ // Otherwise, expect an empty response body.
+ default:
require.Empty(t, rsp.Body.String())
}
if test.wantRedirectLocationRegexp != "" { //nolint:nestif // don't mind have several sequential if statements in this test
require.Len(t, rsp.Header().Values("Location"), 1)
- oidctestutil.RequireAuthcodeRedirectLocation(
+ oidctestutil.RequireAuthCodeRegexpMatch(
t,
rsp.Header().Get("Location"),
test.wantRedirectLocationRegexp,
diff --git a/internal/oidc/clientregistry/clientregistry.go b/internal/oidc/clientregistry/clientregistry.go
index f60cc07d..c01caa7d 100644
--- a/internal/oidc/clientregistry/clientregistry.go
+++ b/internal/oidc/clientregistry/clientregistry.go
@@ -18,10 +18,16 @@ type Client struct {
fosite.DefaultOpenIDConnectClient
}
-// It implements both the base and OIDC client interfaces of Fosite.
+func (c Client) GetResponseModes() []fosite.ResponseModeType {
+ // For now, all Pinniped clients always support "" (unspecified), "query", and "form_post" response modes.
+ return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost}
+}
+
+// It implements both the base, OIDC, and response_mode client interfaces of Fosite.
var (
_ fosite.Client = (*Client)(nil)
_ fosite.OpenIDConnectClient = (*Client)(nil)
+ _ fosite.ResponseModeClient = (*Client)(nil)
)
// StaticClientManager is a fosite.ClientManager with statically-defined clients.
diff --git a/internal/oidc/clientregistry/clientregistry_test.go b/internal/oidc/clientregistry/clientregistry_test.go
index 0da67fcc..5062f629 100644
--- a/internal/oidc/clientregistry/clientregistry_test.go
+++ b/internal/oidc/clientregistry/clientregistry_test.go
@@ -59,6 +59,7 @@ func TestPinnipedCLI(t *testing.T) {
require.Equal(t, "", c.GetRequestObjectSigningAlgorithm())
require.Equal(t, "none", c.GetTokenEndpointAuthMethod())
require.Equal(t, "RS256", c.GetTokenEndpointAuthSigningAlgorithm())
+ require.Equal(t, []fosite.ResponseModeType{"", "query", "form_post"}, c.GetResponseModes())
marshaled, err := json.Marshal(c)
require.NoError(t, err)
diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/oidc/discovery/discovery_handler.go
index e472c012..008808b6 100644
--- a/internal/oidc/discovery/discovery_handler.go
+++ b/internal/oidc/discovery/discovery_handler.go
@@ -25,6 +25,7 @@ type Metadata struct {
JWKSURI string `json:"jwks_uri"`
ResponseTypesSupported []string `json:"response_types_supported"`
+ ResponseModesSupported []string `json:"response_modes_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
@@ -63,6 +64,7 @@ func NewHandler(issuerURL string) http.Handler {
JWKSURI: issuerURL + oidc.JWKSEndpointPath,
SupervisorDiscovery: SupervisorDiscoveryMetadataV1Alpha1{PinnipedIDPsEndpoint: issuerURL + oidc.PinnipedIDPsPathV1Alpha1},
ResponseTypesSupported: []string{"code"},
+ ResponseModesSupported: []string{"query", "form_post"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
diff --git a/internal/oidc/discovery/discovery_handler_test.go b/internal/oidc/discovery/discovery_handler_test.go
index b3c70b35..b1707f77 100644
--- a/internal/oidc/discovery/discovery_handler_test.go
+++ b/internal/oidc/discovery/discovery_handler_test.go
@@ -43,6 +43,7 @@ func TestDiscovery(t *testing.T) {
PinnipedIDPsEndpoint: "https://some-issuer.com/some/path/v1alpha1/pinniped_identity_providers",
},
ResponseTypesSupported: []string{"code"},
+ ResponseModesSupported: []string{"query", "form_post"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"ES256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic"},
diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go
index d92a0f1b..d29979c8 100644
--- a/internal/oidc/oidc.go
+++ b/internal/oidc/oidc.go
@@ -14,6 +14,7 @@ import (
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/jwks"
"go.pinniped.dev/internal/oidc/provider"
+ "go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
@@ -217,7 +218,7 @@ func FositeOauth2Helper(
MinParameterEntropy: fosite.MinParameterEntropy,
}
- return compose.Compose(
+ provider := compose.Compose(
oauthConfig,
oauthStore,
&compose.CommonStrategy{
@@ -233,6 +234,8 @@ func FositeOauth2Helper(
compose.OAuth2PKCEFactory,
TokenExchangeFactory,
)
+ provider.(*fosite.Fosite).FormPostHTMLTemplate = formposthtml.Template()
+ return provider
}
// FositeErrorForLog generates a list of information about the provided Fosite error that can be
diff --git a/internal/oidc/provider/formposthtml/form_post.css b/internal/oidc/provider/formposthtml/form_post.css
new file mode 100644
index 00000000..c65c2fc7
--- /dev/null
+++ b/internal/oidc/provider/formposthtml/form_post.css
@@ -0,0 +1,87 @@
+/* Copyright 2021 the Pinniped contributors. All Rights Reserved. */
+/* SPDX-License-Identifier: Apache-2.0 */
+
+body {
+ font-family: "Metropolis-Light", Helvetica, sans-serif;
+}
+
+h1 {
+ font-size: 20px;
+}
+
+.state {
+ position: absolute;
+ top: 100px;
+ left: 50%;
+ width: 400px;
+ height: 80px;
+ margin-top: -40px;
+ margin-left: -200px;
+ font-size: 14px;
+ line-height: 24px;
+}
+
+button {
+ margin: -10px;
+ padding: 10px;
+ text-align: left;
+ width: 100%;
+ display: inline;
+ border: none;
+ background: none;
+ cursor: pointer;
+ transition: all .1s;
+}
+
+button:hover {
+ background-color: #eee;
+ transform: scale(1.01);
+}
+
+button:active {
+ background-color: #ddd;
+ transform: scale(.99);
+}
+
+code {
+ word-wrap: break-word;
+ hyphens: auto;
+ hyphenate-character: '';
+ font-size: 12px;
+ font-family: monospace;
+ color: #333;
+}
+
+.copy-icon {
+ float: left;
+ width: 36px;
+ height: 36px;
+ padding-top: 2px;
+ padding-right: 10px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ /*
+ This is the "copy-to-clipboard-line.svg" icon from Clarity (https://clarity.design/):
+ https://github.com/vmware/clarity-assets/blob/master/icons/essential/copy-to-clipboard-line.svg
+ */
+ background-image: url("data:image/svg+xml,%3Csvg version='1.1' width='36' height='36' viewBox='0 0 36 36' preserveAspectRatio='xMidYMid meet' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Ecopy-to-clipboard-line%3C/title%3E%3Cpath d='M22.6,4H21.55a3.89,3.89,0,0,0-7.31,0H13.4A2.41,2.41,0,0,0,11,6.4V10H25V6.4A2.41,2.41,0,0,0,22.6,4ZM23,8H13V6.25A.25.25,0,0,1,13.25,6h2.69l.12-1.11A1.24,1.24,0,0,1,16.61,4a2,2,0,0,1,3.15,1.18l.09.84h2.9a.25.25,0,0,1,.25.25Z' class='clr-i-outline clr-i-outline-path-1'%3E%3C/path%3E%3Cpath d='M33.25,18.06H21.33l2.84-2.83a1,1,0,1,0-1.42-1.42L17.5,19.06l5.25,5.25a1,1,0,0,0,.71.29,1,1,0,0,0,.71-1.7l-2.84-2.84H33.25a1,1,0,0,0,0-2Z' class='clr-i-outline clr-i-outline-path-2'%3E%3C/path%3E%3Cpath d='M29,16h2V6.68A1.66,1.66,0,0,0,29.35,5H27.08V7H29Z' class='clr-i-outline clr-i-outline-path-3'%3E%3C/path%3E%3Cpath d='M29,31H7V7H9V5H6.64A1.66,1.66,0,0,0,5,6.67V31.32A1.66,1.66,0,0,0,6.65,33H29.36A1.66,1.66,0,0,0,31,31.33V22.06H29Z' class='clr-i-outline clr-i-outline-path-4'%3E%3C/path%3E%3Crect x='0' y='0' width='36' height='36' fill-opacity='0'/%3E%3C/svg%3E");
+}
+
+@keyframes loader {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+#loading {
+ content: '';
+ box-sizing: border-box;
+ width: 80px;
+ height: 80px;
+ margin-top: -40px;
+ margin-left: -40px;
+ border-radius: 50%;
+ border: 2px solid #fff;
+ border-top-color: #1b3951;
+ animation: loader .6s linear infinite;
+}
diff --git a/internal/oidc/provider/formposthtml/form_post.gohtml b/internal/oidc/provider/formposthtml/form_post.gohtml
new file mode 100644
index 00000000..92be18d2
--- /dev/null
+++ b/internal/oidc/provider/formposthtml/form_post.gohtml
@@ -0,0 +1,34 @@
+
+
+
You have successfully logged in. You may now close this tab.
+To finish logging in, paste this authorization code into your command-line session:
+ +You have successfully logged in. You may now close this tab.
+To finish logging in, paste this authorization code into your command-line session:
+ +