Merge branch 'dynamic_clients' into token_exchange_aud

This commit is contained in:
Ryan Richard
2022-06-08 09:03:29 -07:00
195 changed files with 14800 additions and 285 deletions
@@ -14,6 +14,7 @@ import (
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/util/net"
"k8s.io/apiserver/pkg/authentication/authenticator"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
"k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -118,7 +119,28 @@ func newWebhookAuthenticator(
// custom proxy stuff used by the API server.
var customDial net.DialFunc
// TODO refactor this code to directly construct the rest.Config
// ideally we would keep rest config generation contained to the kubeclient package
// but this will require some form of a new WithTLSConfigFunc kubeclient.Option
// ex:
// _, caBundle, err := pinnipedauthenticator.CABundle(spec.TLS)
// ...
// restConfig := &rest.Config{
// Host: spec.Endpoint,
// TLSClientConfig: rest.TLSClientConfig{CAData: caBundle},
// // copied from k8s.io/apiserver/pkg/util/webhook
// Timeout: 30 * time.Second,
// QPS: -1,
// }
// client, err := kubeclient.New(kubeclient.WithConfig(restConfig), kubeclient.WithTLSConfigFunc(ptls.Default))
// ...
// then use client.JSONConfig as clientConfig
clientConfig, err := webhookutil.LoadKubeconfig(temp.Name(), customDial)
if err != nil {
return nil, err
}
// this uses a http client that does not honor our TLS config
// TODO fix when we pick up https://github.com/kubernetes/kubernetes/pull/106155
return webhook.New(temp.Name(), version, implicitAuds, *webhook.DefaultRetryBackoff(), customDial)
return webhook.New(clientConfig, version, implicitAuds, *webhook.DefaultRetryBackoff())
}
@@ -9,6 +9,7 @@
package mocks
import (
context "context"
reflect "reflect"
gomock "github.com/golang/mock/gomock"
@@ -80,7 +81,7 @@ func (mr *MockDynamicCertPrivateMockRecorder) Name() *gomock.Call {
}
// Run mocks base method.
func (m *MockDynamicCertPrivate) Run(arg0 int, arg1 <-chan struct{}) {
func (m *MockDynamicCertPrivate) Run(arg0 context.Context, arg1 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Run", arg0, arg1)
}
@@ -92,17 +93,17 @@ func (mr *MockDynamicCertPrivateMockRecorder) Run(arg0, arg1 interface{}) *gomoc
}
// RunOnce mocks base method.
func (m *MockDynamicCertPrivate) RunOnce() error {
func (m *MockDynamicCertPrivate) RunOnce(arg0 context.Context) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RunOnce")
ret := m.ctrl.Call(m, "RunOnce", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// RunOnce indicates an expected call of RunOnce.
func (mr *MockDynamicCertPrivateMockRecorder) RunOnce() *gomock.Call {
func (mr *MockDynamicCertPrivateMockRecorder) RunOnce(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunOnce", reflect.TypeOf((*MockDynamicCertPrivate)(nil).RunOnce))
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunOnce", reflect.TypeOf((*MockDynamicCertPrivate)(nil).RunOnce), arg0)
}
// SetCertKeyContent mocks base method.
+4 -3
View File
@@ -1,9 +1,10 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package dynamiccert
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
@@ -148,10 +149,10 @@ func (p *provider) AddListener(listener dynamiccertificates.Listener) {
p.listeners = append(p.listeners, listener)
}
func (p *provider) RunOnce() error {
func (p *provider) RunOnce(_ context.Context) error {
return nil // no-op, but we want to make sure to stay in sync with dynamiccertificates.ControllerRunner
}
func (p *provider) Run(workers int, stopCh <-chan struct{}) {
func (p *provider) Run(_ context.Context, workers int) {
// no-op, but we want to make sure to stay in sync with dynamiccertificates.ControllerRunner
}
+36 -36
View File
@@ -37,7 +37,7 @@ import (
)
const (
someClusterName = "some cluster name"
someUID = "some fake UID"
)
var (
@@ -115,13 +115,13 @@ func TestKubeclient(t *testing.T) {
require.EqualError(t, err, `couldn't find object for path "/api/v1/namespaces/good-namespace/pods/this-pod-does-not-exist"`)
// update
goodPodWithAnnotationsAndLabelsAndClusterName := with(goodPod, annotations(), labels(), clusterName()).(*corev1.Pod)
goodPodWithAnnotationsAndLabelsAndUID := with(goodPod, annotations(), labels(), uid()).(*corev1.Pod)
pod, err = c.Kubernetes.
CoreV1().
Pods(pod.Namespace).
Update(context.Background(), goodPodWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{})
Update(context.Background(), goodPodWithAnnotationsAndLabelsAndUID, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, goodPodWithAnnotationsAndLabelsAndClusterName, pod)
require.Equal(t, goodPodWithAnnotationsAndLabelsAndUID, pod)
// delete
err = c.Kubernetes.
@@ -135,14 +135,14 @@ func TestKubeclient(t *testing.T) {
with(goodPod, gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
with(goodPod, annotations(), labels(), clusterName(), gvk(podGVK)),
with(goodPod, annotations(), labels(), uid(), gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
},
{
with(goodPod, annotations(), gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
with(goodPod, annotations(), labels(), clusterName(), gvk(podGVK)),
with(goodPod, annotations(), labels(), uid(), gvk(podGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(podGVK)),
},
},
@@ -150,12 +150,12 @@ func TestKubeclient(t *testing.T) {
{
with(goodPod, annotations(), labels(), gvk(podGVK)),
with(goodPod, annotations(), labels(), gvk(podGVK)),
with(goodPod, annotations(), labels(), clusterName(), gvk(podGVK)),
with(goodPod, annotations(), labels(), uid(), gvk(podGVK)),
},
{
with(goodPod, emptyAnnotations(), labels(), gvk(podGVK)),
with(goodPod, annotations(), labels(), gvk(podGVK)),
with(goodPod, annotations(), labels(), clusterName(), gvk(podGVK)),
with(goodPod, annotations(), labels(), uid(), gvk(podGVK)),
},
},
},
@@ -217,13 +217,13 @@ func TestKubeclient(t *testing.T) {
require.Equal(t, with(goodAPIService, annotations(), labels()), apiService)
// update
goodAPIServiceWithAnnotationsAndLabelsAndClusterName := with(goodAPIService, annotations(), labels(), clusterName()).(*apiregistrationv1.APIService)
goodAPIServiceWithAnnotationsAndLabelsAndUID := with(goodAPIService, annotations(), labels(), uid()).(*apiregistrationv1.APIService)
apiService, err = c.Aggregation.
ApiregistrationV1().
APIServices().
Update(context.Background(), goodAPIServiceWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{})
Update(context.Background(), goodAPIServiceWithAnnotationsAndLabelsAndUID, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, goodAPIServiceWithAnnotationsAndLabelsAndClusterName, apiService)
require.Equal(t, goodAPIServiceWithAnnotationsAndLabelsAndUID, apiService)
// delete
err = c.Aggregation.
@@ -236,13 +236,13 @@ func TestKubeclient(t *testing.T) {
{
with(goodAPIService, gvk(apiServiceGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), clusterName(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), uid(), gvk(apiServiceGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(apiServiceGVK)),
},
{
with(goodAPIService, annotations(), gvk(apiServiceGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), clusterName(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), uid(), gvk(apiServiceGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(apiServiceGVK)),
},
},
@@ -250,12 +250,12 @@ func TestKubeclient(t *testing.T) {
{
with(goodAPIService, annotations(), labels(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), clusterName(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), uid(), gvk(apiServiceGVK)),
},
{
with(goodAPIService, emptyAnnotations(), labels(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), clusterName(), gvk(apiServiceGVK)),
with(goodAPIService, annotations(), labels(), uid(), gvk(apiServiceGVK)),
},
},
},
@@ -282,13 +282,13 @@ func TestKubeclient(t *testing.T) {
require.Equal(t, with(goodCredentialIssuer, annotations(), labels()), tokenCredentialRequest)
// update
goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName := with(goodCredentialIssuer, annotations(), labels(), clusterName()).(*conciergeconfigv1alpha1.CredentialIssuer)
goodCredentialIssuerWithAnnotationsAndLabelsAndUID := with(goodCredentialIssuer, annotations(), labels(), uid()).(*conciergeconfigv1alpha1.CredentialIssuer)
tokenCredentialRequest, err = c.PinnipedConcierge.
ConfigV1alpha1().
CredentialIssuers().
Update(context.Background(), goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{})
Update(context.Background(), goodCredentialIssuerWithAnnotationsAndLabelsAndUID, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, goodCredentialIssuerWithAnnotationsAndLabelsAndClusterName, tokenCredentialRequest)
require.Equal(t, goodCredentialIssuerWithAnnotationsAndLabelsAndUID, tokenCredentialRequest)
// delete
err = c.PinnipedConcierge.
@@ -301,13 +301,13 @@ func TestKubeclient(t *testing.T) {
{
with(goodCredentialIssuer, gvk(credentialIssuerGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), uid(), gvk(credentialIssuerGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)),
},
{
with(goodCredentialIssuer, annotations(), gvk(credentialIssuerGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), uid(), gvk(credentialIssuerGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(credentialIssuerGVK)),
},
},
@@ -315,12 +315,12 @@ func TestKubeclient(t *testing.T) {
{
with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), uid(), gvk(credentialIssuerGVK)),
},
{
with(goodCredentialIssuer, emptyAnnotations(), labels(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), clusterName(), gvk(credentialIssuerGVK)),
with(goodCredentialIssuer, annotations(), labels(), uid(), gvk(credentialIssuerGVK)),
},
},
},
@@ -347,13 +347,13 @@ func TestKubeclient(t *testing.T) {
require.Equal(t, with(goodFederationDomain, annotations(), labels()), federationDomain)
// update
goodFederationDomainWithAnnotationsAndLabelsAndClusterName := with(goodFederationDomain, annotations(), labels(), clusterName()).(*supervisorconfigv1alpha1.FederationDomain)
goodFederationDomainWithAnnotationsAndLabelsAndUID := with(goodFederationDomain, annotations(), labels(), uid()).(*supervisorconfigv1alpha1.FederationDomain)
federationDomain, err = c.PinnipedSupervisor.
ConfigV1alpha1().
FederationDomains(federationDomain.Namespace).
Update(context.Background(), goodFederationDomainWithAnnotationsAndLabelsAndClusterName, metav1.UpdateOptions{})
Update(context.Background(), goodFederationDomainWithAnnotationsAndLabelsAndUID, metav1.UpdateOptions{})
require.NoError(t, err)
require.Equal(t, goodFederationDomainWithAnnotationsAndLabelsAndClusterName, federationDomain)
require.Equal(t, goodFederationDomainWithAnnotationsAndLabelsAndUID, federationDomain)
// delete
err = c.PinnipedSupervisor.
@@ -366,13 +366,13 @@ func TestKubeclient(t *testing.T) {
{
with(goodFederationDomain, gvk(federationDomainGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), clusterName(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), uid(), gvk(federationDomainGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
},
{
with(goodFederationDomain, annotations(), gvk(federationDomainGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), clusterName(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), uid(), gvk(federationDomainGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
},
},
@@ -380,12 +380,12 @@ func TestKubeclient(t *testing.T) {
{
with(goodFederationDomain, annotations(), labels(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), clusterName(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), uid(), gvk(federationDomainGVK)),
},
{
with(goodFederationDomain, emptyAnnotations(), labels(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), clusterName(), gvk(federationDomainGVK)),
with(goodFederationDomain, annotations(), labels(), uid(), gvk(federationDomainGVK)),
},
},
},
@@ -428,7 +428,7 @@ func TestKubeclient(t *testing.T) {
FederationDomains(goodFederationDomain.Namespace).
Create(context.Background(), goodFederationDomain, metav1.CreateOptions{})
require.NoError(t, err)
require.Equal(t, with(goodFederationDomain, clusterName()), federationDomain)
require.Equal(t, with(goodFederationDomain, uid()), federationDomain)
// read
federationDomain, err = c.PinnipedSupervisor.
@@ -436,7 +436,7 @@ func TestKubeclient(t *testing.T) {
FederationDomains(federationDomain.Namespace).
Get(context.Background(), federationDomain.Name, metav1.GetOptions{})
require.NoError(t, err)
require.Equal(t, with(goodFederationDomain, clusterName()), federationDomain)
require.Equal(t, with(goodFederationDomain, uid()), federationDomain)
},
wantMiddlewareReqs: [][]Object{
{
@@ -444,7 +444,7 @@ func TestKubeclient(t *testing.T) {
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
},
{
with(goodFederationDomain, clusterName(), gvk(federationDomainGVK)),
with(goodFederationDomain, uid(), gvk(federationDomainGVK)),
with(&metav1.PartialObjectMetadata{}, gvk(federationDomainGVK)),
},
},
@@ -502,7 +502,7 @@ func TestKubeclient(t *testing.T) {
name: "non-pertinent mutater",
t: t,
mutateReq: func(rt RoundTrip, obj Object) error {
clusterName()(obj)
uid()(obj)
return nil
},
}}
@@ -728,7 +728,7 @@ func newSimpleMiddleware(t *testing.T, hasMutateReqFunc, mutatedReq, hasMutateRe
m.mutateReq = func(rt RoundTrip, obj Object) error {
if mutatedReq {
if rt.Verb() == VerbCreate {
obj.SetClusterName(someClusterName)
obj.SetUID(someUID)
}
}
return nil
@@ -916,9 +916,9 @@ func labels() withFunc {
}
}
func clusterName() withFunc {
func uid() withFunc {
return func(obj Object) {
obj.SetClusterName(someClusterName)
obj.SetUID(someUID)
}
}
+8 -2
View File
@@ -24,6 +24,7 @@ import (
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/login"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/nonce"
@@ -46,7 +47,7 @@ func NewHandler(
upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec,
) 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 {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// Authorization Servers MUST support the use of the HTTP GET and POST methods defined in
@@ -105,7 +106,12 @@ func NewHandler(
upstreamStateEncoder,
cookieCodec,
)
}))
})
// During a response_mode=form_post auth request using the browser flow, the custom form_post html page may
// be used to post certain errors back to the CLI from this handler's response, so allow the form_post
// page's CSS and JS to run.
return securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy())
}
func handleAuthRequestForLDAPUpstreamCLIFlow(
+20 -1
View File
@@ -521,6 +521,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantStatus int
wantContentType string
wantBodyString string
wantBodyRegex string
wantBodyJSON string
wantCSRFValueInCookieHeader string
wantBodyStringWithLocationInHref bool
@@ -1537,6 +1538,20 @@ func TestAuthorizationEndpoint(t *testing.T) {
wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery),
wantBodyString: "",
},
{
name: "form_post page is used to send errors to client using OIDC upstream browser flow with response_mode=form_post",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
generateCSRF: happyCSRFGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
stateEncoder: happyStateEncoder,
cookieEncoder: happyCookieEncoder,
method: http.MethodGet,
path: modifiedHappyGetRequestPath(map[string]string{"response_mode": "form_post", "scope": "openid profile email tuna"}),
wantStatus: http.StatusOK,
wantContentType: htmlContentType,
wantBodyRegex: `<input type="hidden" name="encoded_params" value="error=invalid_scope&amp;error_description=The&#43;requested&#43;scope&#43;is&#43;invalid`,
},
{
name: "downstream scopes do not match what is configured for client using LDAP upstream",
idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider),
@@ -2578,7 +2593,9 @@ func TestAuthorizationEndpoint(t *testing.T) {
require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
testutil.RequireSecurityHeadersWithoutCustomCSPs(t, rsp)
// Use form_post page's CSPs because sometimes errors are sent to the client via the form_post page.
testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp)
if test.wantPasswordGrantCall != nil {
test.wantPasswordGrantCall.args.Ctx = reqContext
@@ -2645,6 +2662,8 @@ func TestAuthorizationEndpoint(t *testing.T) {
default:
t.Errorf("unexpected response code: %v", code)
}
case test.wantBodyRegex != "":
require.Regexp(t, test.wantBodyRegex, rsp.Body.String())
default:
require.Equal(t, test.wantBodyString, rsp.Body.String())
}
@@ -30,7 +30,7 @@ var (
<head>
<meta charset="UTF-8">
<style>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:0 0;cursor:pointer;transition:all .1s}button:hover{background-color:#eee;transform:scale(1.01)}button:active{background-color:#ddd;transform:scale(.99)}code{display:block;word-wrap:break-word;word-break:break-all;font-size:12px;font-family:monospace;color:#333}.copy-icon{float:left;width:36px;height:36px;margin-top:-3px;margin-right:10px;background-size:contain;background-repeat:no-repeat;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}</style>
<script>window.onload=()=>{const e=t=>{Array.from(document.querySelectorAll(".state")).forEach(e=>e.hidden=!0);const e=document.getElementById(t);e.hidden=!1,document.title=e.dataset.title,document.getElementById("favicon").setAttribute("href","data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>"+e.dataset.favicon+"</text></svg>")};e("loading"),window.history.replaceState(null,'',"./"),document.getElementById("manual-copy-button").onclick=()=>{const e=document.getElementById("manual-copy-button").innerText;navigator.clipboard.writeText(e).then(()=>console.info("copied authorization code "+e+" to clipboard")).catch(t=>console.error("failed to copy code "+e+" to clipboard: "+t))};const n=setTimeout(()=>e("manual"),2e3),t=document.forms[0].elements;fetch(t.redirect_uri.value,{method:"POST",mode:"no-cors",headers:{'Content-Type':"application/x-www-form-urlencoded;charset=UTF-8"},body:t.encoded_params.value}).then(t=>{clearTimeout(n),e("success")}).catch(()=>e("manual"))}</script>
<script>window.onload=()=>{const e=e=>{Array.from(document.querySelectorAll(".state")).forEach(e=>e.hidden=!0);const t=document.getElementById(e);t.hidden=!1,document.title=t.dataset.title,document.getElementById("favicon").setAttribute("href","data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>"+t.dataset.favicon+"</text></svg>")};e("loading"),window.history.replaceState(null,"","./"),document.getElementById("manual-copy-button").onclick=()=>{const e=document.getElementById("manual-copy-button").innerText;navigator.clipboard.writeText(e).then(()=>console.info("copied authorization code "+e+" to clipboard")).catch(t=>console.error("failed to copy code "+e+" to clipboard: "+t))};const n=setTimeout(()=>e("manual"),2e3),t=document.forms[0].elements;fetch(t.redirect_uri.value,{method:"POST",mode:"no-cors",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8"},body:t.encoded_params.value}).then(t=>{clearTimeout(n),e("success")}).catch(()=>e("manual"))}</script>
<link id="favicon" rel="icon"/>
</head>
<body>
@@ -61,7 +61,7 @@ var (
// It's okay if this changes in the future, but this gives us a chance to eyeball the formatting.
// Our browser-based integration tests should find any incompatibilities.
testExpectedCSP = `default-src 'none'; ` +
`script-src 'sha256-1LS3gM7wTGc0dYXZiqW6HK1LHk74YSG8GsJBC/j1/i8='; ` +
`script-src 'sha256-uIWC0J7wd7tWtcXmugZCkKsQpqOsQzqBI/mfQMtUde0='; ` +
`style-src 'sha256-kXh6OrB2z7wkx7v1N3ay9deQhV5edwuogARaUtvNYN4='; ` +
`img-src data:; ` +
`connect-src *; ` +