ContainerImage.Pinniped/proposals/1125_dynamic-supervisor-oidc-clients/ACCEPTANCE-NOTES.md
Ryan Richard 1c296e5c4c Implement the OIDCClientSecretRequest API
This commit is a WIP commit because it doesn't include many tests
for the new feature.

Co-authored-by: Ryan Richard <richardry@vmware.com>
Co-authored-by: Benjamin A. Petersen <ben@benjaminapetersen.me>
2022-09-21 15:15:07 -07:00

8.4 KiB

Notes for story acceptance for the dynamic clients feature

Rather than writing a webapp to manually test the dynamic client features during user story acceptance, we can simulate the requests that a webapp would make to the Supervisor using the commands shown below. The commands below the happy path for a fully-capable OIDCClient which is allowed to use all supported grant types and scopes. These commands can be adjusted to test other scenarios of interest.

Deploy and configure a basic Supervisor locally

We can use the developer hack scripts to deploy a working Supervisor on a local Kind cluster. These clusters have no ingress, so we use Kind's port mapping feature to expose a web proxy outside the cluster. The proxy can then be used to access the Supervisor. In this setup, the Supervisor's CA is not trusted by the web browser, however, the curl commands can trust the CA cert by using the --cacert flag.

./hack/prepare-for-integration-tests.sh -c
source /tmp/integration-test-env
# We'll use LDAP so we can login in via curl commands through the Supervisor.
./hack/prepare-supervisor-on-kind.sh --ldap --flow browser_authcode

Alternatively, the Supervisor could be installed into a cluster in a more production-like way, with ingress, a DNS entry, and TLS certs. In this case, the proxy env vars used below would not be needed, and the issuer string would be adjusted to match the Supervisor's ingress DNS hostname.

Create an OIDCClient

cat <<EOF | kubectl apply -f -
apiVersion: config.supervisor.pinniped.dev/v1alpha1
kind: OIDCClient
metadata:
  # name must have client.oauth.pinniped.dev- prefix
  name: client.oauth.pinniped.dev-my-webapp-client
  namespace: supervisor # must be in the same namespace as the Supervisor
spec:
  allowedRedirectURIs:
    - https://webapp.example.com/callback
  allowedGrantTypes:
    - authorization_code
    - refresh_token
    - urn:ietf:params:oauth:grant-type:token-exchange
  allowedScopes:
    - openid
    - offline_access
    - pinniped:request-audience
    - username
    - groups
EOF

Get the OIDCClient to check its status:

kubectl get oidcclient -n supervisor client.oauth.pinniped.dev-my-webapp-client -o yaml

Create a client secret for the OIDCClient:

cat <<EOF | kubectl create -o yaml -f -
apiVersion: clientsecret.supervisor.pinniped.dev/v1alpha1
kind: OIDCClientSecretRequest
metadata:
  name: client.oauth.pinniped.dev-my-webapp-client # the name of the OIDCClient
  namespace: supervisor # the namespace of the OIDCClient
spec:
  generateNewSecret: true
EOF

Example response:

apiVersion: clientsecret.supervisor.pinniped.dev/v1alpha1
kind: OIDCClientSecretRequest
metadata:
  creationTimestamp: null
spec:
  generateNewSecret: false
  revokeOldSecrets: false
status:
  generatedSecret: 0cc65d46fb5c0fb80123b28bd8093ae0e61e568b6c35cbca82941dcaa8c67b5b
  totalClientSecrets: 1

Make a note of the generatedSecret value. It will never be shown again.

Make an authorization request

The OIDC authcode flow always starts with an authorization request. A webapp would redirect the user's browser to make this request in a browser. For story acceptance, this request could also be made in a web browser by typing the full URL with params into the browser's address bar, although here we'll show how to use curl to ensure that we are documenting the exact requirements of the authorization request.

Authorization parameter notes:

  • Authorization requests must use PKCE. For manual testing, these sample values can be used. For production use, each authorization request must have a new PKCE value computed for that request.
    • Example code challenge: vTu6b5Jm2hpi1vjRJw7HB820EYNq7AFT1IHDLBQMc3Q
    • Example code verifier: UDABWPiROQh0nfhGzd_7OetrEJZZ7S-Z_H8_ZLB2i8Yc2wix
  • Nonce values should also be unique per authorization request in production use.
  • State values are optional and will be passed back in the authcode callback if provided.
PARAMS='?response_type=code'\
'&client_id=client.oauth.pinniped.dev-my-webapp-client'\
'&code_challenge=vTu6b5Jm2hpi1vjRJw7HB820EYNq7AFT1IHDLBQMc3Q'\
'&code_challenge_method=S256'\
'&nonce=9902045656a1c29b95515f7f45b40773'\
'&redirect_uri=https%3A%2F%2Fwebapp.example.com%2Fcallback'\
'&scope=openid+offline_access+username+groups+pinniped%3Arequest-audience'\
'&state=cfcd3a3e72774bee1e748e6bf4a70f5c'

https_proxy="http://127.0.0.1:12346" no_proxy="127.0.0.1" \
  curl -vfLsS --cookie-jar cookies.txt --cacert root_ca.crt \
  "https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/oauth2/authorize$PARAMS"

When successful, this should redirect to the Supervisor's LDAP login page and return its HTML. The resulting HTML form will include a hidden param called state. Make a note of its value for the next step.

The LDAP login page's form can be submitted with:

STATE='COPY_PASTE_HIDDEN_STATE_PARAM_FROM_PREVIOUS_CURL_RESULT_HERE'

https_proxy="http://127.0.0.1:12346" no_proxy="127.0.0.1" \
  curl -vfsS --cookie cookies.txt --cacert root_ca.crt \
  "https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/login" \
  --form-string "username=$PINNIPED_TEST_LDAP_USER_CN" \
  --form-string "password=$PINNIPED_TEST_LDAP_USER_PASSWORD" \
  --form-string "state=$STATE"

When successful, this should result in an HTTP 302 or 303 redirect response. The location header should look something like https://webapp.example.com/callback?code=pin_ac_oq7m9z...wuzQ&scope=openid+offline_access+pinniped%3Arequest-audience+username+groups&state=cfcd3a3e72774bee1e748e6bf4a70f5c which includes the authcode as the code param. Make a note of its value for the next step.

Make a token request to exchange the authcode obtained in the previous step

The authcode callback would be handled by the webapp's backend. The backend code would then use the authcode to make a token request to the Supervisor. This would happen as a backend request, so the user's browser would not be involved.

CODE='COPY_AUTHCODE_FROM_PREVIOUS_CURL_RESULT_HERE'
CLIENT_SECRET='COPY_CLIENT_SECRET_HERE'

https_proxy="http://127.0.0.1:12346" no_proxy="127.0.0.1" \
  curl -vfsS --cacert root_ca.crt \
  "https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/oauth2/token" \
  --form-string "grant_type=authorization_code" \
  --form-string "code=$CODE" \
  --form-string "redirect_uri=https://webapp.example.com/callback" \
  --form-string "code_verifier=UDABWPiROQh0nfhGzd_7OetrEJZZ7S-Z_H8_ZLB2i8Yc2wix" \
  -u "client.oauth.pinniped.dev-my-webapp-client:$CLIENT_SECRET"

When successful, this should return some JSON which includes the Supervisor-issued tokens. The ID token can be decoded for inspection (e.g. using https://jwt.io). Make a note of the access token and the refresh token for the next steps.

Make a request for a cluster-scoped ID token

If the webapp wanted to access a Kubernetes cluster on behalf of the end user, it would need to make an additional request (per cluster) to get a cluster-scoped ID token.

ACCESS='COPY_ACCESS_TOKEN_FROM_PREVIOUS_CURL_RESULT_HERE'

https_proxy="http://127.0.0.1:12346" no_proxy="127.0.0.1" \
  curl -vfsS --cacert root_ca.crt \
  "https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/oauth2/token" \
  --form-string "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  --form-string "audience=my-workload-cluster-audience-name" \
  --form-string "subject_token=$ACCESS" \
  --form-string "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  --form-string "requested_token_type=urn:ietf:params:oauth:token-type:jwt" \
  -u "client.oauth.pinniped.dev-my-webapp-client:$CLIENT_SECRET"

If successful, this should return some JSON with a new cluster-scoped ID token in the response.

Make a refresh request

The ID and access tokens are very short-lived, so the backend of the webapp should refresh them as needed.

REFRESH='COPY_REFRESH_TOKEN_FROM_PREVIOUS_CURL_RESULT_HERE'

https_proxy="http://127.0.0.1:12346" no_proxy="127.0.0.1" \
  curl -vfsS --cacert root_ca.crt \
  "https://pinniped-supervisor-clusterip.supervisor.svc.cluster.local/some/path/oauth2/token" \
  --form-string "grant_type=refresh_token" \
  --form-string "refresh_token=$REFRESH" \
  -u "client.oauth.pinniped.dev-my-webapp-client:$CLIENT_SECRET"

When successful, this should return some JSON which includes the new Supervisor-issued tokens. The old refresh token is revoked and the next refresh request must use the newest refresh token.