From 00cc91c102bcc4df7980d990ad00bba48a68ebb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 18 Feb 2026 15:13:24 +0400 Subject: [PATCH] Fix assume SAML required error redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../iam/AssumePageMutation.graphql.ts | 40 ++--- .../iam/AssumePageQuery.graphql.ts | 108 +++++++++++++ .../pages/iam/organizations/AssumePage.tsx | 36 +++-- .../iam/organizations/AssumePageLoader.tsx | 30 ++++ apps/console/src/routes.tsx | 2 +- pkg/iam/errors.go | 7 +- pkg/iam/organization_service.go | 3 +- pkg/iam/session_service.go | 7 +- pkg/server/api/connect/v1/schema.graphql | 5 +- pkg/server/api/connect/v1/schema/schema.go | 153 ++++++++++++------ pkg/server/api/connect/v1/types/types.go | 4 +- pkg/server/api/connect/v1/v1_resolver.go | 47 +++++- 12 files changed, 340 insertions(+), 102 deletions(-) create mode 100644 apps/console/src/__generated__/iam/AssumePageQuery.graphql.ts create mode 100644 apps/console/src/pages/iam/organizations/AssumePageLoader.tsx diff --git a/apps/console/src/__generated__/iam/AssumePageMutation.graphql.ts b/apps/console/src/__generated__/iam/AssumePageMutation.graphql.ts index 3a947be8d..ff90451dc 100644 --- a/apps/console/src/__generated__/iam/AssumePageMutation.graphql.ts +++ b/apps/console/src/__generated__/iam/AssumePageMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<799dc16fa7e9b770dbaba857b2d887ef>> * @lightSyntaxTransform * @nogrep */ @@ -25,7 +25,6 @@ export type AssumePageMutation$data = { } | { readonly __typename: "SAMLAuthenticationRequired"; readonly reason: ReauthenticationReason; - readonly redirectUrl: string; } | { // This will never be '%other', but we need some // value in case none of the concrete values match. @@ -46,13 +45,15 @@ var v0 = [ "name": "input" } ], -v1 = { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "reason", - "storageKey": null -}, +v1 = [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "reason", + "storageKey": null + } +], v2 = [ { "alias": null, @@ -85,24 +86,13 @@ v2 = [ }, { "kind": "InlineFragment", - "selections": [ - (v1/*: any*/) - ], + "selections": (v1/*: any*/), "type": "PasswordRequired", "abstractKey": null }, { "kind": "InlineFragment", - "selections": [ - (v1/*: any*/), - { - "alias": null, - "args": null, - "kind": "ScalarField", - "name": "redirectUrl", - "storageKey": null - } - ], + "selections": (v1/*: any*/), "type": "SAMLAuthenticationRequired", "abstractKey": null } @@ -131,16 +121,16 @@ return { "selections": (v2/*: any*/) }, "params": { - "cacheID": "073df17c6a8b470ae74d1d39243b3e30", + "cacheID": "d8d9f257921f8f642821de2af7f22bcb", "id": null, "metadata": {}, "name": "AssumePageMutation", "operationKind": "mutation", - "text": "mutation AssumePageMutation(\n $input: AssumeOrganizationSessionInput!\n) {\n assumeOrganizationSession(input: $input) {\n result {\n __typename\n ... on PasswordRequired {\n reason\n }\n ... on SAMLAuthenticationRequired {\n reason\n redirectUrl\n }\n }\n }\n}\n" + "text": "mutation AssumePageMutation(\n $input: AssumeOrganizationSessionInput!\n) {\n assumeOrganizationSession(input: $input) {\n result {\n __typename\n ... on PasswordRequired {\n reason\n }\n ... on SAMLAuthenticationRequired {\n reason\n }\n }\n }\n}\n" } }; })(); -(node as any).hash = "e6f39c43ccc7680cd72ad99c84a82780"; +(node as any).hash = "289d3534a87fa6cdb46384af573d5a57"; export default node; diff --git a/apps/console/src/__generated__/iam/AssumePageQuery.graphql.ts b/apps/console/src/__generated__/iam/AssumePageQuery.graphql.ts new file mode 100644 index 000000000..373675db3 --- /dev/null +++ b/apps/console/src/__generated__/iam/AssumePageQuery.graphql.ts @@ -0,0 +1,108 @@ +/** + * @generated SignedSource<<480c5420502cccfbc55c32a61e1662c3>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type AssumePageQuery$variables = Record; +export type AssumePageQuery$data = { + readonly viewer: { + readonly __typename: "Identity"; + readonly ssoLoginURL: string | null | undefined; + }; +}; +export type AssumePageQuery = { + response: AssumePageQuery$data; + variables: AssumePageQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "ssoLoginURL", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "AssumePageQuery", + "selections": [ + { + "kind": "RequiredField", + "field": { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "storageKey": null + }, + "action": "THROW" + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [], + "kind": "Operation", + "name": "AssumePageQuery", + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Identity", + "kind": "LinkedField", + "name": "viewer", + "plural": false, + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "f922b5514212bff8d2da85b12f00c51e", + "id": null, + "metadata": {}, + "name": "AssumePageQuery", + "operationKind": "query", + "text": "query AssumePageQuery {\n viewer {\n __typename\n ssoLoginURL\n id\n }\n}\n" + } +}; +})(); + +(node as any).hash = "266ce6c5836b9e826718b07e03fcc36e"; + +export default node; diff --git a/apps/console/src/pages/iam/organizations/AssumePage.tsx b/apps/console/src/pages/iam/organizations/AssumePage.tsx index 7ae837a85..c2c99d08d 100644 --- a/apps/console/src/pages/iam/organizations/AssumePage.tsx +++ b/apps/console/src/pages/iam/organizations/AssumePage.tsx @@ -1,13 +1,13 @@ import { useTranslate } from "@probo/i18n"; import { UnAuthenticatedError } from "@probo/relay"; import { useEffect } from "react"; -import { useMutation } from "react-relay"; +import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; import { useNavigate, useSearchParams } from "react-router"; import { graphql } from "relay-runtime"; import type { AssumePageMutation } from "#/__generated__/iam/AssumePageMutation.graphql"; +import type { AssumePageQuery } from "#/__generated__/iam/AssumePageQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; -import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; import AuthLayout from "../auth/AuthLayout"; @@ -23,19 +23,32 @@ const assumeMutation = graphql` } ... on SAMLAuthenticationRequired { reason - redirectUrl } } } } `; -function AssumePageInner() { +export const assumePageQuery = graphql` + query AssumePageQuery { + viewer @required(action: THROW) { + __typename + ... on Identity { + ssoLoginURL + } + } + } +`; + +export function AssumePage(props: { queryRef: PreloadedQuery }) { + const { queryRef } = props; + const organizationId = useOrganizationId(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { __ } = useTranslate(); + const { viewer } = usePreloadedQuery(assumePageQuery, queryRef); const [assumeOrganizationSession] = useMutation(assumeMutation); const continueUrlParam = searchParams.get("continue"); @@ -80,7 +93,10 @@ function AssumePageInner() { void navigate({ pathname: "/auth/password-login", search: "?" + search.toString() }); break; case "SAMLAuthenticationRequired": - samlSSOLoginURL = new URL(result.redirectUrl); + if (!viewer.ssoLoginURL) { + throw new Error("missing SSO login URL for user email"); + } + samlSSOLoginURL = new URL(viewer.ssoLoginURL); samlSSOLoginURL.search = "?" + searchParams.toString(); window.location.href = samlSSOLoginURL.toString(); @@ -90,7 +106,7 @@ function AssumePageInner() { } }, }); - }, [organizationId, navigate, assumeOrganizationSession, safeContinueUrl, searchParams]); + }, [organizationId, navigate, assumeOrganizationSession, safeContinueUrl, searchParams, viewer.ssoLoginURL]); return ( @@ -105,11 +121,3 @@ function AssumePageInner() { ); } - -export default function AssumePage() { - return ( - - - - ); -} diff --git a/apps/console/src/pages/iam/organizations/AssumePageLoader.tsx b/apps/console/src/pages/iam/organizations/AssumePageLoader.tsx new file mode 100644 index 000000000..180d4e9ef --- /dev/null +++ b/apps/console/src/pages/iam/organizations/AssumePageLoader.tsx @@ -0,0 +1,30 @@ +import { CenteredLayoutSkeleton } from "@probo/ui"; +import { useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { AssumePageQuery } from "#/__generated__/iam/AssumePageQuery.graphql"; +import { IAMRelayProvider } from "#/providers/IAMRelayProvider"; + +import { AssumePage, assumePageQuery } from "./AssumePage"; + +function AssumePageQueryLoader() { + const [queryRef, loadQuery] = useQueryLoader(assumePageQuery); + + useEffect(() => { + loadQuery({}, { fetchPolicy: "network-only" }); + }, [loadQuery]); + + if (!queryRef) { + return ; + } + + return ; +} + +export default function AssumePageLoader() { + return ( + + + + ); +} diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index 638a7302c..3f017c0ea 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -125,7 +125,7 @@ const routes = [ children: [ { path: "assume", - Component: lazy(() => import("./pages/iam/organizations/AssumePage")), + Component: lazy(() => import("./pages/iam/organizations/AssumePageLoader")), }, { path: "employee", diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index 057b6b934..1398dfa9f 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -339,12 +339,11 @@ func (e *ErrPasswordRequired) Error() string { } type ErrSAMLAuthenticationRequired struct { - Reason string - RedirectURL string + Reason string } -func NewSAMLAuthenticationRequiredError(reason string, redirectURL string) *ErrSAMLAuthenticationRequired { - return &ErrSAMLAuthenticationRequired{Reason: reason, RedirectURL: redirectURL} +func NewSAMLAuthenticationRequiredError(reason string) *ErrSAMLAuthenticationRequired { + return &ErrSAMLAuthenticationRequired{Reason: reason} } func (e *ErrSAMLAuthenticationRequired) Error() string { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index f54f35429..b0e2a5351 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -1301,7 +1301,8 @@ func (s OrganizationService) DeleteSAMLConfiguration( func (s OrganizationService) ListSAMLConfigurations( ctx context.Context, - organizationID gid.GID, cursor *page.Cursor[coredata.SAMLConfigurationOrderField], + organizationID gid.GID, + cursor *page.Cursor[coredata.SAMLConfigurationOrderField], ) (*page.Page[*coredata.SAMLConfiguration, coredata.SAMLConfigurationOrderField], error) { var ( scope = coredata.NewScopeFromObjectID(organizationID) diff --git a/pkg/iam/session_service.go b/pkg/iam/session_service.go index 7889a5c6e..a6e5da5d2 100644 --- a/pkg/iam/session_service.go +++ b/pkg/iam/session_service.go @@ -549,12 +549,7 @@ func (s SessionService) AssumeOrganizationSession( if err == nil && samlConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyRequired { if rootSession.AuthMethod != coredata.AuthMethodSAML { - redirectURL, err := s.SAMLService.InitiateLogin(ctx, samlConfig.ID, continueURL) - if err != nil { - return fmt.Errorf("cannot initiate SAML login: %w", err) - } - - return NewSAMLAuthenticationRequiredError("policy_requirement", redirectURL.String()) + return NewSAMLAuthenticationRequiredError("policy_requirement") } } else if err == nil && samlConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyOptional { // SAML is optional: both PASSWORD and SAML root sessions are allowed. diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 31b6719cb..46f9935a6 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -169,6 +169,10 @@ type Identity implements Node { before: CursorKey ): PersonalAPIKeyConnection @goField(forceResolver: true) + ssoLoginURL: String + @goField(forceResolver: true) + @session(required: PRESENT) + permission(action: String!): Boolean! @goField(forceResolver: true) @session(required: PRESENT) @@ -856,7 +860,6 @@ type PasswordRequired { type SAMLAuthenticationRequired { reason: ReauthenticationReason! - redirectUrl: String! } type AssumeOrganizationSessionPayload { diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index c4b99a3ae..f875f96ef 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -154,6 +154,7 @@ type ComplexityRoot struct { Permission func(childComplexity int, action string) int PersonalAPIKeys func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey) int Sessions func(childComplexity int, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SessionOrder) int + SsoLoginURL func(childComplexity int) int UpdatedAt func(childComplexity int) int } @@ -354,8 +355,7 @@ type ComplexityRoot struct { } SAMLAuthenticationRequired struct { - Reason func(childComplexity int) int - RedirectURL func(childComplexity int) int + Reason func(childComplexity int) int } SAMLConfiguration struct { @@ -506,6 +506,7 @@ type IdentityResolver interface { PendingInvitations(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrderBy) (*types.InvitationConnection, error) Sessions(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.SessionOrder) (*types.SessionConnection, error) PersonalAPIKeys(ctx context.Context, obj *types.Identity, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.PersonalAPIKeyConnection, error) + SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) Permission(ctx context.Context, obj *types.Identity, action string) (bool, error) } type InvitationResolver interface { @@ -895,6 +896,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Identity.Sessions(childComplexity, args["first"].(*int), args["after"].(*page.CursorKey), args["last"].(*int), args["before"].(*page.CursorKey), args["orderBy"].(*types.SessionOrder)), true + case "Identity.ssoLoginURL": + if e.complexity.Identity.SsoLoginURL == nil { + break + } + + return e.complexity.Identity.SsoLoginURL(childComplexity), true case "Identity.updatedAt": if e.complexity.Identity.UpdatedAt == nil { break @@ -1871,12 +1878,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SAMLAuthenticationRequired.Reason(childComplexity), true - case "SAMLAuthenticationRequired.redirectUrl": - if e.complexity.SAMLAuthenticationRequired.RedirectURL == nil { - break - } - - return e.complexity.SAMLAuthenticationRequired.RedirectURL(childComplexity), true case "SAMLConfiguration.attributeMappings": if e.complexity.SAMLConfiguration.AttributeMappings == nil { @@ -2654,7 +2655,6 @@ type Mutation { updateSCIMBridge( input: UpdateSCIMBridgeInput! ): UpdateSCIMBridgePayload @session(required: PRESENT) - } type Identity implements Node { @@ -2696,6 +2696,10 @@ type Identity implements Node { before: CursorKey ): PersonalAPIKeyConnection @goField(forceResolver: true) + ssoLoginURL: String + @goField(forceResolver: true) + @session(required: PRESENT) + permission(action: String!): Boolean! @goField(forceResolver: true) @session(required: PRESENT) @@ -3383,7 +3387,6 @@ type PasswordRequired { type SAMLAuthenticationRequired { reason: ReauthenticationReason! - redirectUrl: String! } type AssumeOrganizationSessionPayload { @@ -3500,8 +3503,6 @@ type RegenerateSCIMTokenPayload { type UpdateSCIMBridgePayload { scimBridge: SCIMBridge! } - - `, BuiltIn: false}, {Name: "../../../../gqlutils/directives/session/schema.graphql", Input: `# Session directive for GraphQL APIs # Include this schema in your gqlgen configuration to enable session-based access control. @@ -5587,6 +5588,53 @@ func (ec *executionContext) fieldContext_Identity_personalAPIKeys(ctx context.Co return fc, nil } +func (ec *executionContext) _Identity_ssoLoginURL(ctx context.Context, field graphql.CollectedField, obj *types.Identity) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Identity_ssoLoginURL, + func(ctx context.Context) (any, error) { + return ec.resolvers.Identity().SsoLoginURL(ctx, obj) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + required, err := ec.unmarshalNSessionRequirement2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋgqlutilsᚋdirectivesᚋsessionᚐSessionRequirement(ctx, "PRESENT") + if err != nil { + var zeroVal *string + return zeroVal, err + } + if ec.directives.Session == nil { + var zeroVal *string + return zeroVal, errors.New("directive session is not implemented") + } + return ec.directives.Session(ctx, obj, directive0, required) + } + + next = directive1 + return next + }, + ec.marshalOString2ᚖstring, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Identity_ssoLoginURL(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Identity", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Identity_permission(ctx context.Context, field graphql.CollectedField, obj *types.Identity) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6320,6 +6368,8 @@ func (ec *executionContext) fieldContext_Membership_identity(_ context.Context, return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -7130,6 +7180,8 @@ func (ec *executionContext) fieldContext_MembershipProfile_identity(_ context.Co return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -10565,6 +10617,8 @@ func (ec *executionContext) fieldContext_Query_viewer(_ context.Context, field g return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -11107,35 +11161,6 @@ func (ec *executionContext) fieldContext_SAMLAuthenticationRequired_reason(_ con return fc, nil } -func (ec *executionContext) _SAMLAuthenticationRequired_redirectUrl(ctx context.Context, field graphql.CollectedField, obj *types.SAMLAuthenticationRequired) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_SAMLAuthenticationRequired_redirectUrl, - func(ctx context.Context) (any, error) { - return obj.RedirectURL, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_SAMLAuthenticationRequired_redirectUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "SAMLAuthenticationRequired", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _SAMLConfiguration_id(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -13059,6 +13084,8 @@ func (ec *executionContext) fieldContext_Session_identity(_ context.Context, fie return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -13495,6 +13522,8 @@ func (ec *executionContext) fieldContext_SignInPayload_identity(_ context.Contex return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -13624,6 +13653,8 @@ func (ec *executionContext) fieldContext_SignUpFromInvitationPayload_identity(_ return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -13677,6 +13708,8 @@ func (ec *executionContext) fieldContext_SignUpPayload_identity(_ context.Contex return ec.fieldContext_Identity_sessions(ctx, field) case "personalAPIKeys": return ec.fieldContext_Identity_personalAPIKeys(ctx, field) + case "ssoLoginURL": + return ec.fieldContext_Identity_ssoLoginURL(ctx, field) case "permission": return ec.fieldContext_Identity_permission(ctx, field) } @@ -17737,6 +17770,39 @@ func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet, continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "ssoLoginURL": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Identity_ssoLoginURL(ctx, field, obj) + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "permission": field := field @@ -19919,11 +19985,6 @@ func (ec *executionContext) _SAMLAuthenticationRequired(ctx context.Context, sel if out.Values[i] == graphql.Null { out.Invalids++ } - case "redirectUrl": - out.Values[i] = ec._SAMLAuthenticationRequired_redirectUrl(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 642581acf..f48617f61 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -180,6 +180,7 @@ type Identity struct { PendingInvitations *InvitationConnection `json:"pendingInvitations,omitempty"` Sessions *SessionConnection `json:"sessions,omitempty"` PersonalAPIKeys *PersonalAPIKeyConnection `json:"personalAPIKeys,omitempty"` + SsoLoginURL *string `json:"ssoLoginURL,omitempty"` Permission bool `json:"permission"` } @@ -387,8 +388,7 @@ type SAMLAttributeMappingsInput struct { } type SAMLAuthenticationRequired struct { - Reason ReauthenticationReason `json:"reason"` - RedirectURL string `json:"redirectUrl"` + Reason ReauthenticationReason `json:"reason"` } func (SAMLAuthenticationRequired) IsAssumeOrganizationSessionResult() {} diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 6e08ea286..2c9a78baf 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -160,6 +160,50 @@ func (r *identityResolver) PersonalAPIKeys(ctx context.Context, obj *types.Ident return types.NewPersonalAPIKeyConnection(page, r, obj.ID), nil } +// SsoLoginURL is the resolver for the ssoLoginURL field. +func (r *identityResolver) SsoLoginURL(ctx context.Context, obj *types.Identity) (*string, error) { + if err := r.authorize(ctx, obj.ID, iam.ActionIdentityGet); err != nil { + return nil, err + } + + identity := authn.IdentityFromContext(ctx) + + count, err := r.iam.AccountService.CountSAMLConfigurationsForEmail(ctx, identity.EmailAddress) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count SAML configurations for email", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + if count != 1 { + if count == 0 { + return nil, graphql.ErrorOnPath( + ctx, + fmt.Errorf("no SAML configuration for email"), + ) + } + + return nil, graphql.ErrorOnPath( + ctx, + fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"), + ) + } + + samlConfigs, err := r.iam.AccountService.ListSAMLConfigurationsForEmail(ctx, identity.EmailAddress) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list SAML configurations for email", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + samlConfig := samlConfigs[0] + if samlConfig == nil { + r.logger.ErrorCtx(ctx, "cannot find SAML config") + return nil, gqlutils.NotFoundf(ctx, "cannot find SAML config") + } + + loginURL := r.baseURL.WithPath("/api/connect/v1/saml/2.0/" + samlConfig.ID.String()).MustString() + return &loginURL, nil +} + // Permission is the resolver for the permission field. func (r *identityResolver) Permission(ctx context.Context, obj *types.Identity, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -749,8 +793,7 @@ func (r *mutationResolver) AssumeOrganizationSession(ctx context.Context, input case errors.As(err, &errSAMLAuthenticationRequired): return &types.AssumeOrganizationSessionPayload{ Result: types.SAMLAuthenticationRequired{ - Reason: types.ReauthenticationReason(errSAMLAuthenticationRequired.Reason), - RedirectURL: errSAMLAuthenticationRequired.RedirectURL, + Reason: types.ReauthenticationReason(errSAMLAuthenticationRequired.Reason), }, }, nil