diff --git a/README.md b/README.md index d7fbe72..2a343a9 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ spec: namespace: krateo-system filter: .groups.value | map(.displayName) ``` -To obtain groups through the rest action, add `Directory.Read.All` in the `additionalScopes`. +To obtain groups through the rest action, add `Directory.Read.All` in the `additionalScopes`. There is a more complex example for the Microsoft Entra API that also uses pagination in the testdata folder (requires Authn >= 0.22.0). On AuthN: - To obtain the user avatar/profile image include `User.Read` in the `additionalScopes` field of the OIDCConfiguration custom resource; diff --git a/internal/helpers/restaction/resolver.go b/internal/helpers/restaction/resolver.go index a6b8095..ba0d508 100644 --- a/internal/helpers/restaction/resolver.go +++ b/internal/helpers/restaction/resolver.go @@ -23,6 +23,48 @@ type Response struct { } func Resolve(ctx context.Context, rc *rest.Config, restaction *core.ObjectRef, email string, bearerToken string) (map[string]interface{}, error) { + jsonToken, _ := json.Marshal(map[string]string{ + "token": bearerToken, + }) + // Call the RESTAction from snowplow + url := fmt.Sprintf(ctx.Value(RestActionContextKey("snowplowURL")).(string)+ + "/call?apiVersion=templates.krateo.io%%2Fv1&resource=restactions&name=%s&namespace=%s&extras=%s", + restaction.Name, + restaction.Namespace, + jsonToken) + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create http request for restaction call to snowplow: %w", err) + } + + jwt, ok := xcontext.AccessToken(ctx) + if !ok { + return nil, fmt.Errorf("failed to retrieve jwt token for authn") + } + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt)) + + resp, err := http.DefaultClient.Do(request) + if err != nil { + return nil, fmt.Errorf("failed to send http request for restaction call to snowplow: %w", err) + } + responseRaw, _ := io.ReadAll(resp.Body) + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("snowplow endpoint returned a non-200 status code: %s", string(body)) + } + + var responseStruct Response + err = json.Unmarshal(responseRaw, &responseStruct) + if err != nil { + return nil, fmt.Errorf("error parsing userinfo payload JSON: %v\npayload: %s", err, string(responseRaw)) + } + + return responseStruct.Status, nil +} + +func LegacyResolve(ctx context.Context, rc *rest.Config, restaction *core.ObjectRef, email string, bearerToken string) (map[string]interface{}, error) { restactionCopy, err := copyRestActionWithEndpoints(ctx, rc, restaction, email, bearerToken) if err != nil { return nil, fmt.Errorf("could not resolve restaction: %v", err) diff --git a/internal/routes/auth/oauth/login.go b/internal/routes/auth/oauth/login.go index 9e76a71..642928c 100644 --- a/internal/routes/auth/oauth/login.go +++ b/internal/routes/auth/oauth/login.go @@ -109,10 +109,16 @@ func (r *loginRoute) Handler() http.HandlerFunc { return } additionalFieldstoReplace, err := restaction.Resolve(r.ctx, r.rc, restactionRef, uuid.New().String(), tok.AccessToken) - if err != nil { - log.Err(err).Str("name", name).Msg("unable to resolve restaction") - encode.InternalError(wri, err) - return + value, ok := additionalFieldstoReplace["name"] + _, okk := additionalFieldstoReplace["name"].(string) + if err != nil || !ok || !okk || value == nil { + log.Err(err).Str("name", name).Msg("unable to resolve restaction, retrying with legacy resolve (copy)") + additionalFieldstoReplace, err = restaction.LegacyResolve(r.ctx, r.rc, restactionRef, uuid.New().String(), tok.AccessToken) + if err != nil { + log.Err(err).Str("name", name).Msg("unable to resolve restaction, stopping") + encode.InternalError(wri, err) + return + } } log.Debug().Str("name", name).Msgf("values to replace: %s", additionalFieldstoReplace) userinfo, err = updateConfig(userinfo, additionalFieldstoReplace) diff --git a/internal/routes/auth/oidc/login.go b/internal/routes/auth/oidc/login.go index 6bb1f67..495778b 100644 --- a/internal/routes/auth/oidc/login.go +++ b/internal/routes/auth/oidc/login.go @@ -94,10 +94,16 @@ func (r *loginRoute) Handler() http.HandlerFunc { log.Debug().Str("name", name).Msg("resolving restaction") if cfg.RESTActionRef != nil { additionalFieldstoReplace, err := restaction.Resolve(r.ctx, r.rc, cfg.RESTActionRef, idToken.email, idToken.bearerToken) - if err != nil { - log.Err(err).Str("name", name).Msg("unable to resolve restaction") - encode.InternalError(wri, err) - return + value, ok := additionalFieldstoReplace["name"] + _, okk := additionalFieldstoReplace["name"].(string) + if err != nil || !ok || !okk || value == nil { + log.Err(err).Str("name", name).Msg("unable to resolve restaction, retrying with legacy resolve (copy)") + additionalFieldstoReplace, err = restaction.LegacyResolve(r.ctx, r.rc, cfg.RESTActionRef, idToken.email, idToken.bearerToken) + if err != nil { + log.Err(err).Str("name", name).Msg("unable to resolve restaction, stopping") + encode.InternalError(wri, err) + return + } } log.Debug().Str("name", name).Msg("updating oidc idtoken") log.Debug().Str("name", name).Msgf("old idToken - name: %s - preferredUsername: %s - email: %s - groups: %s - avatarURL: %s", idToken.name, idToken.preferredUsername, idToken.email, idToken.groups, idToken.avatarURL) diff --git a/testdata/oauth.yaml b/testdata/oauth.yaml index cfc6d17..9ee334b 100644 --- a/testdata/oauth.yaml +++ b/testdata/oauth.yaml @@ -39,6 +39,7 @@ spec: headers: - 'Accept: application/vnd.github+json' - 'X-GitHub-Api-Version: 2022-11-28' + - "${ \"Authorization: Bearer \" + .token }" path: "/user" endpointRef: name: github-api @@ -49,6 +50,7 @@ spec: verb: POST headers: - 'Content-Type: application/json' + - "${ \"Authorization: Bearer \" + .token }" path: "" payload: | ${ {query:"query{organization(login:\"krateoplatformops\"){teams(first:100,userLogins:[\"" + .userInfo.name + "\"]){edges{node{slug}}}}}"} } diff --git a/testdata/oidc-azure-pagination.yaml b/testdata/oidc-azure-pagination.yaml new file mode 100644 index 0000000..bf617bb --- /dev/null +++ b/testdata/oidc-azure-pagination.yaml @@ -0,0 +1,98 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: oidc-example-secret + namespace: demo-system +stringData: + clientSecret: +--- +apiVersion: "v1" +kind: Secret +metadata: + name: azure-entra + namespace: krateo-system +stringData: + server-url: https://graph.microsoft.com +--- +apiVersion: oidc.authn.krateo.io/v1alpha1 +kind: OIDCConfig +metadata: + name: oidc-example + namespace: demo-system +spec: + discoveryURL: https:///.well-known/openid-configuration + # Use these three fields if you do not have a discovery endpoint + # authorizationURL: authorization endpoint + # tokenURL: token endpoint + # userInfoURL: userinfo endpoint + redirectURI: http://localhost:8080/auth?kind=oidc # While any redirect URI can be used, the Krateo frontend requires /auth?kind=oidc + clientID: + clientSecret: + name: oidc-example-secret + namespace: demo-system + key: clientSecret + additionalScopes: # e.g., "User.Read Directory.Read.All" for Azure + restActionRef: # optional + name: test-rest-action + namespace: krateo-system + graphics: + icon: "fa-brands fa-windows" + displayName: "Login with Azure" + backgroundColor: "#4444ff" + textColor: "#ffffff" +--- +apiVersion: templates.krateo.io/v1 +kind: RESTAction +metadata: + name: test-rest-action + namespace: krateo-system +spec: + api: + - name: groups + verb: GET + continueOnError: false + errorKey: groupsError + headers: + - 'Accept: application/json' + - "${ \"Authorization: Bearer \" + .token }" + path: ${ .next // "v1.0/me/memberOf?$select=displayName&" } + endpointRef: + name: azure-entra + namespace: krateo-system + filter: | + .groups | if has("@odata.nextLink") then + { + res: (.value | map(.displayName)), + next: (."@odata.nextLink" | sub("https://graph.microsoft.com"; "")) + } + else + { + res: (.value | map(.displayName)) + } + end + - name: recursive + verb: GET + exportJwt: true + continueOnError: false + errorKey: recursiveError + dependsOn: + name: groups + iterator: | + .token as $token | .groups | if has("next") then [{next: .next, token: $token}] else [] end + headers: + - 'Accept: application/json' + path: | + ${ "/call?apiVersion=templates.krateo.io/v1&resource=restactions&name=test-rest-action&namespace=krateo-system&extras=" + ({token: .token, next: .next } | tostring | @uri ) } + endpointRef: + name: snowplow-endpoint + namespace: krateo-system + filter: .recursive.status + filter: | + .groups.res as $groups | + .recursive | + if type == "object" then + .groups += $groups + elif type == "null" then + {"groups": $groups} + end diff --git a/testdata/oidc-azure.yaml b/testdata/oidc-azure.yaml index ab368a3..e28e736 100644 --- a/testdata/oidc-azure.yaml +++ b/testdata/oidc-azure.yaml @@ -53,6 +53,7 @@ spec: verb: GET headers: - 'Accept: application/json' + - "${ \"Authorization: Bearer \" + .token }" path: "v1.0/me/memberOf?$select=displayName" endpointRef: name: azure-entra