From 4423d472dacceb483dfbfba8f8e2a4a46db392b8 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 20 Nov 2024 13:22:31 -0800 Subject: [PATCH] allow audit correlation between token being issued and being used --- internal/auditevent/audit_event.go | 51 ++++-- .../endpoints/token/token_handler.go | 52 +++++++ .../endpoints/token/token_handler_test.go | 147 +++++++++++++----- internal/registry/credentialrequest/rest.go | 9 ++ .../registry/credentialrequest/rest_test.go | 41 +++++ site/content/docs/reference/audit-logging.md | 3 + 6 files changed, 244 insertions(+), 59 deletions(-) diff --git a/internal/auditevent/audit_event.go b/internal/auditevent/audit_event.go index 363b06bea..0a0960360 100644 --- a/internal/auditevent/audit_event.go +++ b/internal/auditevent/audit_event.go @@ -6,25 +6,42 @@ package auditevent type Message string const ( - HTTPRequestReceived Message = "HTTP Request Received" - HTTPRequestCompleted Message = "HTTP Request Completed" - HTTPRequestParameters Message = "HTTP Request Parameters" - HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" - UsingUpstreamIDP Message = "Using Upstream IDP" - AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" - IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" - IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" - SessionStarted Message = "Session Started" - SessionRefreshed Message = "Session Refreshed" - SessionFound Message = "Session Found" - AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" - UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential - SessionGarbageCollected Message = "Session Garbage Collected" - UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" - OIDCClientSecretRequestUpdatedSecrets Message = "OIDCClientSecretRequest Updated Secrets" + // Supervisor request logging. + + HTTPRequestReceived Message = "HTTP Request Received" + HTTPRequestCompleted Message = "HTTP Request Completed" + HTTPRequestParameters Message = "HTTP Request Parameters" + HTTPRequestCustomHeadersUsed Message = "HTTP Request Custom Headers Used" + HTTPRequestBasicAuthUsed Message = "HTTP Request Basic Auth" + + // Supervisor authentication logging. + + UsingUpstreamIDP Message = "Using Upstream IDP" + AuthorizeIDFromParameters Message = "AuthorizeID From Parameters" + IdentityFromUpstreamIDP Message = "Identity From Upstream IDP" + UpstreamAuthorizeRedirect Message = "Upstream Authorize Redirect" + IdentityRefreshedFromUpstreamIDP Message = "Identity Refreshed From Upstream IDP" + IDTokenIssued Message = "ID Token Issued" //nolint:gosec // this is not a credential + SessionStarted Message = "Session Started" + SessionRefreshed Message = "Session Refreshed" + SessionFound Message = "Session Found" + AuthenticationRejectedByTransforms Message = "Authentication Rejected By Transforms" + IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" + + // Supervisor session ending logging. + + UpstreamOIDCTokenRevoked Message = "Upstream OIDC Token Revoked" //nolint:gosec // this is not a credential + SessionGarbageCollected Message = "Session Garbage Collected" + + // Supervisor aggregated APIs logging. + + OIDCClientSecretRequestUpdatedSecrets Message = "OIDCClientSecretRequest Updated Secrets" + + // Concierge aggregated APIs logging. + + TokenCredentialRequestTokenReceived Message = "TokenCredentialRequest Token Received" //nolint:gosec // this is not a credential TokenCredentialRequestAuthenticatedUser Message = "TokenCredentialRequest Authenticated User" //nolint:gosec // this is not a credential TokenCredentialRequestAuthenticationFailed Message = "TokenCredentialRequest Authentication Failed" //nolint:gosec // this is not a credential TokenCredentialRequestUnexpectedError Message = "TokenCredentialRequest Unexpected Error" //nolint:gosec // this is not a credential TokenCredentialRequestUnsupportedUserInfo Message = "TokenCredentialRequest Unsupported UserInfo" //nolint:gosec // this is not a credential - IncorrectUsernameOrPassword Message = "Incorrect Username Or Password" //nolint:gosec // this is not a credential ) diff --git a/internal/federationdomain/endpoints/token/token_handler.go b/internal/federationdomain/endpoints/token/token_handler.go index 86909c9c9..7dd0ef053 100644 --- a/internal/federationdomain/endpoints/token/token_handler.go +++ b/internal/federationdomain/endpoints/token/token_handler.go @@ -6,6 +6,7 @@ package token import ( "context" + "crypto/sha256" "errors" "fmt" "net/http" @@ -56,6 +57,7 @@ func NewHandler( oauthHelper.WriteAccessError(r.Context(), w, nil, err) return nil } + auditLogBasicAuthClientID(r, auditLogger) session := psession.NewPinnipedSession() accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session) @@ -114,6 +116,9 @@ func NewHandler( return nil } + // Allow cross-referencing the token with the Concierge's audit logs. + auditLogIDToken(r.Context(), auditLogger, accessRequest, accessResponse) + oauthHelper.WriteAccessResponse(r.Context(), w, accessRequest, accessResponse) return nil @@ -391,3 +396,50 @@ func diffSortedGroups(oldGroups, newGroups []string) ([]string, []string) { removed := oldGroupsAsSet.Difference(newGroupsAsSet) // groups in oldGroups that are not in newGroups i.e. removed return added.List(), removed.List() } + +func auditLogBasicAuthClientID(r *http.Request, auditLogger plog.AuditLogger) { + // For dynamic clients, the client ID is from basic auth, not from the request parameters. + clientIDFromBasicAuth, _, basicAuthUsed := r.BasicAuth() + if basicAuthUsed { + auditLogger.Audit(auditevent.HTTPRequestBasicAuthUsed, &plog.AuditParams{ + ReqCtx: r.Context(), + KeysAndValues: []any{"clientID", clientIDFromBasicAuth}, + }) + } +} + +func auditLogIDToken( + reqCtx context.Context, + auditLogger plog.AuditLogger, + accessRequest fosite.AccessRequester, + accessResponse fosite.AccessResponder, +) { + var idToken string + + if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange) { + // Token exchanges return the ID token in the access token field of the response. + idToken = accessResponse.GetAccessToken() + } else { + // For other grant types, there may not be an access token, e.g. when the openid scope was not granted. + tok := accessResponse.GetExtra("id_token") + if tok != nil { + // This should always be a string. Checking just to be safe. + tokAsStr, ok := tok.(string) + if ok { + idToken = tokAsStr + } + } + } + + if len(idToken) == 0 { + return + } + + auditLogger.Audit(auditevent.IDTokenIssued, &plog.AuditParams{ + ReqCtx: reqCtx, + Session: accessRequest, + KeysAndValues: []any{ + "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))), + }, + }) +} diff --git a/internal/federationdomain/endpoints/token/token_handler_test.go b/internal/federationdomain/endpoints/token/token_handler_test.go index 2c5174790..343da2035 100644 --- a/internal/federationdomain/endpoints/token/token_handler_test.go +++ b/internal/federationdomain/endpoints/token/token_handler_test.go @@ -312,7 +312,7 @@ type tokenEndpointResponseExpectedValues struct { // The expected lifetime of the ID tokens issued by authcode exchange and refresh, but not token exchange. // When zero, will assume that the test wants the default value for ID token lifetime. wantIDTokenLifetimeSeconds int - wantAuditLogs func(sessionID string) []testutil.WantedAuditLog + wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog } func withWantCustomIDTokenLifetime(wantIDTokenLifetimeSeconds int, w tokenEndpointResponseExpectedValues) tokenEndpointResponseExpectedValues { @@ -368,6 +368,10 @@ func addDynamicClientIDToFormPostBody(r *http.Request) { r.Form.Set("client_id", dynamicClientID) } +func idTokenToHash(tok string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(tok))) +} + func TestTokenEndpointAuthcodeExchange(t *testing.T) { tests := []struct { name string @@ -387,7 +391,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -399,6 +403,10 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -458,7 +466,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"openid", "pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -468,7 +476,12 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { "redirect_uri": "http://127.0.0.1/callback", }, }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -549,7 +562,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"username", "groups"}, // username and groups were not requested, but granted anyway for backwards compatibility wantUsername: goodUsername, wantGroups: goodGroups, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -583,6 +596,21 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { wantGrantedScopes: []string{"pinniped:request-audience", "username", "groups"}, wantUsername: goodUsername, wantGroups: goodGroups, + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { + return []testutil.WantedAuditLog{ + testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ + "params": map[string]any{ + "code": "redacted", + "code_verifier": "redacted", + "grant_type": "authorization_code", + "redirect_uri": "http://127.0.0.1/callback", + }, + }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), + testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + // Note that there was no ID token issued, so there is no "ID Token Issued" audit log. + } + }, }, }, }, @@ -955,7 +983,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -981,7 +1009,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusBadRequest, wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1144,7 +1172,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus int wantErrorType string wantErrorDescContains string - wantAuditLogs func(sessionID string) []testutil.WantedAuditLog + wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog }{ { name: "happy path", @@ -1188,7 +1216,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "name": "value", }, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1200,13 +1228,17 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, }, requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1219,6 +1251,10 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -1394,7 +1430,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "unauthorized_client", wantErrorDescContains: `The client is not authorized to request a token using this method. The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'.`, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1405,6 +1441,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", }, }), + testutil.WantAuditLog("HTTP Request Basic Auth", map[string]any{"clientID": dynamicClientID}), } }, }, @@ -1505,7 +1542,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantErrorType: "invalid_request", wantErrorDescContains: "Missing 'audience' parameter.", - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -1784,7 +1821,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn t.Parallel() // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - subject, rsp, _, _, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, + subject, rsp, _, _, secrets, oauthStore, actualAuditLog, actualSessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, testidplister.NewUpstreamIDPListerBuilder().BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -1824,12 +1861,6 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Equal(t, test.wantStatus, rsp.Code) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") - if test.wantAuditLogs != nil { - wantAuditLogs := test.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } - var parsedResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -1845,6 +1876,13 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.NotEmpty(t, errorDesc) require.Contains(t, errorDesc, test.wantErrorDescContains) + // Even in the error case, make assertions about audit logs, but without an ID token. + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, "") + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } + // The remaining assertions apply only to the happy path. return } @@ -1860,7 +1898,8 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn require.Equal(t, "urn:ietf:params:oauth:token-type:jwt", parsedResponseBody["issued_token_type"]) // Parse the returned token. - parsedJWT, err := jose.ParseSigned(parsedResponseBody["access_token"].(string), []jose.SignatureAlgorithm{jose.ES256}) + actualIDToken := parsedResponseBody["access_token"].(string) + parsedJWT, err := jose.ParseSigned(actualIDToken, []jose.SignatureAlgorithm{jose.ES256}) require.NoError(t, err) var tokenClaims map[string]any require.NoError(t, json.Unmarshal(parsedJWT.UnsafePayloadWithoutVerification(), &tokenClaims)) @@ -1948,6 +1987,12 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn newSecrets, err := secrets.List(context.Background(), metav1.ListOptions{}) require.NoError(t, err) require.ElementsMatch(t, existingSecrets.Items, newSecrets.Items) + + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, actualIDToken) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-token-exchange-audit-id") + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } }) } } @@ -2184,7 +2229,7 @@ func TestRefreshGrant(t *testing.T) { return want } - refreshResponseWithAuditLogs := func(expectedValues tokenEndpointResponseExpectedValues, wantAuditLogs func(sessionID string) []testutil.WantedAuditLog) tokenEndpointResponseExpectedValues { + refreshResponseWithAuditLogs := func(expectedValues tokenEndpointResponseExpectedValues, wantAuditLogs func(sessionID string, idToken string) []testutil.WantedAuditLog) tokenEndpointResponseExpectedValues { expectedValues.wantAuditLogs = wantAuditLogs return expectedValues } @@ -2338,7 +2383,7 @@ func TestRefreshGrant(t *testing.T) { upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), refreshedUpstreamTokensWithIDAndRefreshTokens(), ), - func(sessionID string) []testutil.WantedAuditLog { + func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2367,6 +2412,10 @@ func TestRefreshGrant(t *testing.T) { "subject": "https://issuer?sub=some-subject", }, }), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, ), @@ -2536,7 +2585,7 @@ func TestRefreshGrant(t *testing.T) { "error_description": "Error during upstream refresh. Upstream refresh rejected by configured identity policy: authentication was rejected by a configured policy." } `), - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2607,7 +2656,7 @@ func TestRefreshGrant(t *testing.T) { "name": "value", }, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -2619,6 +2668,10 @@ func TestRefreshGrant(t *testing.T) { }, }), testutil.WantAuditLog("Session Found", map[string]any{"sessionID": sessionID}), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -2973,7 +3026,7 @@ func TestRefreshGrant(t *testing.T) { {Text: `User "some-username" has been added to the following groups: ["new-group1" "new-group2" "new-group3"]`}, {Text: `User "some-username" has been removed from the following groups: ["group1" "groups2"]`}, }, - wantAuditLogs: func(sessionID string) []testutil.WantedAuditLog { + wantAuditLogs: func(sessionID string, idToken string) []testutil.WantedAuditLog { return []testutil.WantedAuditLog{ testutil.WantAuditLog("HTTP Request Parameters", map[string]any{ "params": map[string]any{ @@ -3007,6 +3060,10 @@ func TestRefreshGrant(t *testing.T) { "subject": "https://issuer?sub=some-subject", }, }), + testutil.WantAuditLog("ID Token Issued", map[string]any{ + "sessionID": sessionID, + "tokenIdentifier": idTokenToHash(idToken), + }), } }, }, @@ -4955,7 +5012,7 @@ func TestRefreshGrant(t *testing.T) { // First exchange the authcode for tokens, including a refresh token. // It's actually fine to use this function even when simulating LDAP (which uses a different flow) because it's // just populating a secret in storage. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID := exchangeAuthcodeForTokens(t, + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, actualSessionID := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.BuildFederationDomainIdentityProvidersListerFinder(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]any require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -4999,12 +5056,6 @@ func TestRefreshGrant(t *testing.T) { t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) - if test.refreshRequest.want.wantAuditLogs != nil { - wantAuditLogs := test.refreshRequest.want.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-refresh-grant-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } - // Test that we did or did not make a call to the upstream provider's interface to perform refresh. switch { case test.refreshRequest.want.wantOIDCUpstreamRefreshCall != nil: @@ -5067,6 +5118,9 @@ func TestRefreshGrant(t *testing.T) { jwtSigningKey, secrets, approxRequestTime, + actualSessionID, + "fake-refresh-grant-audit-id", + actualAuditLog, ) if test.refreshRequest.want.wantStatus == http.StatusOK { @@ -5141,7 +5195,7 @@ func exchangeAuthcodeForTokens( secrets v1.SecretInterface, oauthStore *storage.KubeStorage, actualAuditLog *bytes.Buffer, - sessionID string, + actualSessionID string, ) { authRequest := deepCopyRequestForm(happyAuthRequest) if test.modifyAuthRequest != nil { @@ -5206,13 +5260,7 @@ func exchangeAuthcodeForTokens( t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) - sessionID = getSessionID(t, secrets) - - if test.want.wantAuditLogs != nil { - wantAuditLogs := test.want.wantAuditLogs(sessionID) - testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, "fake-code-grant-audit-id") - testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) - } + actualSessionID = getSessionID(t, secrets) wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) @@ -5226,9 +5274,12 @@ func exchangeAuthcodeForTokens( jwtSigningKey, secrets, approxRequestTime, + actualSessionID, + "fake-code-grant-audit-id", + actualAuditLog, ) - return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, sessionID + return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore, actualAuditLog, actualSessionID } func getSessionID(t *testing.T, secrets v1.SecretInterface) string { @@ -5255,10 +5306,14 @@ func requireTokenEndpointBehavior( jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, requestTime time.Time, + actualSessionID string, + wantAuditID string, + actualAuditLog *bytes.Buffer, ) { testutil.RequireEqualContentType(t, tokenEndpointResponse.Header().Get("Content-Type"), "application/json") require.Equal(t, test.wantStatus, tokenEndpointResponse.Code) + var actualIDToken string if test.wantStatus == http.StatusOK { require.NotNil(t, test.wantSuccessBodyFields, "problem with test table setup: wanted success but did not specify expected response body") @@ -5279,7 +5334,7 @@ func requireTokenEndpointBehavior( expectedNumberOfRefreshTokenSessionsStored = 1 } if wantIDToken { - requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantNonceValueInIDToken, test.wantUsername, test.wantGroups, test.wantAdditionalClaims, test.wantIDTokenLifetimeSeconds, parsedResponseBody["access_token"].(string), requestTime) + actualIDToken = requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantNonceValueInIDToken, test.wantUsername, test.wantGroups, test.wantAdditionalClaims, test.wantIDTokenLifetimeSeconds, parsedResponseBody["access_token"].(string), requestTime) } if wantRefreshToken { requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantUsername, test.wantGroups, test.wantCustomSessionDataStored, test.wantAdditionalClaims, secrets, requestTime) @@ -5297,6 +5352,12 @@ func requireTokenEndpointBehavior( require.JSONEq(t, test.wantErrorResponseBody, tokenEndpointResponse.Body.String()) } + + if test.wantAuditLogs != nil { + wantAuditLogs := test.wantAuditLogs(actualSessionID, actualIDToken) + testutil.WantAuditIDOnEveryAuditLog(wantAuditLogs, wantAuditID) + testutil.CompareAuditLogs(t, wantAuditLogs, actualAuditLog.String()) + } } func hashAccessToken(accessToken string) string { @@ -5806,7 +5867,7 @@ func requireValidIDToken( wantIDTokenLifetimeSeconds int, actualAccessToken string, requestTime time.Time, -) { +) string { t.Helper() idToken, ok := body["id_token"] @@ -5891,6 +5952,8 @@ func requireValidIDToken( require.NotEmpty(t, actualAccessToken) require.Equal(t, hashAccessToken(actualAccessToken), claims.AccessTokenHash) + + return idTokenString } func deepCopyRequestForm(r *http.Request) *http.Request { diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index cf987f770..a05da3189 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -6,6 +6,7 @@ package credentialrequest import ( "context" + "crypto/sha256" "errors" "fmt" "time" @@ -112,6 +113,14 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation return nil, err } + // Allow cross-referencing the token with the Supervisor's audit logs. + r.auditLogger.Audit(auditevent.TokenCredentialRequestTokenReceived, &plog.AuditParams{ + ReqCtx: ctx, + KeysAndValues: []any{ + "tokenIdentifier", fmt.Sprintf("%x", sha256.Sum256([]byte(credentialRequest.Spec.Token))), + }, + }) + userInfo, err := r.authenticator.AuthenticateTokenCredentialRequest(ctx, credentialRequest) if err != nil { r.auditLogger.Audit(auditevent.TokenCredentialRequestUnexpectedError, &plog.AuditParams{ diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 564bbf7eb..b970a6c25 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -6,6 +6,7 @@ package credentialrequest import ( "bytes" "context" + "crypto/sha256" "errors" "fmt" "testing" @@ -67,6 +68,10 @@ func TestNew(t *testing.T) { require.Error(t, err, "the resource panda.bears does not support being converted to a Table") } +func tokenToHash(tok string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(tok))) +} + func TestCreate(t *testing.T) { spec.Run(t, "create", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions @@ -125,6 +130,10 @@ func TestCreate(t *testing.T) { }) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -162,6 +171,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -188,6 +201,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authentication Failed", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -214,6 +231,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unexpected Error", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -241,6 +262,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -277,6 +302,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -313,6 +342,10 @@ func TestCreate(t *testing.T) { requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Unsupported UserInfo", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -389,6 +422,10 @@ func TestCreate(t *testing.T) { r.NotEmpty(response) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ @@ -436,6 +473,10 @@ func TestCreate(t *testing.T) { r.Empty(validationFunctionSawTokenValue) wantAuditLog = []testutil.WantedAuditLog{ + testutil.WantAuditLog("TokenCredentialRequest Token Received", map[string]any{ + "auditID": "fake-audit-id", + "tokenIdentifier": tokenToHash(req.Spec.Token), + }), testutil.WantAuditLog("TokenCredentialRequest Authenticated User", map[string]any{ "auditID": "fake-audit-id", "authenticator": map[string]any{ diff --git a/site/content/docs/reference/audit-logging.md b/site/content/docs/reference/audit-logging.md index e1acbe0c6..6fc74b00c 100644 --- a/site/content/docs/reference/audit-logging.md +++ b/site/content/docs/reference/audit-logging.md @@ -92,6 +92,9 @@ correlate an audit event log line to other logs. The values for these keys are o - When applicable, audit logs have an `authorizeID` which is a unique ID to allow audit events to be correlated across some of the browser redirects which relate to a single login attempt by an end user. This is only applicable to those browser-based login flows which use redirects to identity providers and/or interstitial pages in the login flow. +- When applicable, audit logs have a `tokenIdentifier` which is a unique ID of a token to allow audit events to be correlated + between where a token is issued to an end user in the Supervisor and where a token is used to gain access to a + Kubernetes cluster in the Concierge. Each audit event may also have more key-value pairs specific to the event's type.