diff --git a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx index 909f398e2..ca90e50e4 100644 --- a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx @@ -1,13 +1,33 @@ +import { formatError, type GraphQLError } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; -import { Button } from "@probo/ui"; -import { Suspense } from "react"; -import { useLazyLoadQuery } from "react-relay"; -import { Link, useLocation } from "react-router"; +import { Button, Field, Google, Microsoft, useToast } from "@probo/ui"; +import { type ComponentProps, type FormEventHandler, Suspense } from "react"; +import { useLazyLoadQuery, useMutation } from "react-relay"; +import { Link, useLocation, matchPath } from "react-router"; import { graphql } from "relay-runtime"; +import type { SignInPageMutation } from "#/__generated__/iam/SignInPageMutation.graphql"; import type { SignInPageQuery } from "#/__generated__/iam/SignInPageQuery.graphql"; import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl"; +const providerIcons: Record< + string, + (props: ComponentProps<"svg">) => React.ReactNode +> = { + google: Google, + microsoft: Microsoft, +}; + +const signInMutation = graphql` + mutation SignInPageMutation($input: SignInInput!) { + signIn(input: $input) { + session { + id + } + } + } +`; + const oidcProvidersQuery = graphql` query SignInPageQuery { oidcProviders { @@ -17,6 +37,17 @@ const oidcProvidersQuery = graphql` } `; +function Divider({ children }: { children: React.ReactNode }) { + return ( +
+
+ + {children} + +
+ ); +} + function OIDCButtons() { const { __ } = useTranslate(); const safeContinueUrl = useSafeContinueUrl(); @@ -29,88 +60,158 @@ function OIDCButtons() { return ( <> - {data.oidcProviders.map((provider) => ( - - ))} + {data.oidcProviders.map((provider) => { + const Icon = providerIcons[provider.name]; + return ( + + ); + })} ); } export default function SignInPage() { const { __ } = useTranslate(); - + const { toast } = useToast(); const location = useLocation(); + const safeContinueUrl = useSafeContinueUrl(); + + const [signIn, isSigningIn] = + useMutation(signInMutation); + + const handleSubmit: FormEventHandler = (e) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const email = (formData.get("email") as string) ?? ""; + const password = (formData.get("password") as string) ?? ""; + + if (!email || !password) return; + + const match = matchPath( + { + path: "/organizations/:organizationId", + caseSensitive: false, + end: false, + }, + safeContinueUrl.pathname, + ); + + signIn({ + variables: { + input: { + email, + password, + organizationId: match?.params.organizationId ?? null, + }, + }, + onCompleted: (_, error) => { + if (error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to sign in"), + error as GraphQLError, + ), + variant: "error", + }); + return; + } + + window.location.href = safeContinueUrl.href; + }, + onError: (e) => { + toast({ + title: __("Error"), + description: e.message, + variant: "error", + }); + }, + }); + }; return ( -
-

- {__("Login to your account")} +
+

+ {__("Sign in to your account")}

-

- {__("Choose your login method")} -

- +
+ - - - +
+
+ + + {__("Forgot your password?")} + +
+ +
-
-
- + {isSigningIn ? __("Signing in...") : __("Sign in")} + + + +
+ {__("Or")} + + + + + +
- - -
- {__("Don't have an account ?")} +

+ {__("New to Probo?")} {" "} - {__("Register")} + {__("Create account")} -

- -
- {__("Forgot password?")} - {" "} - - {__("Reset password")} - -
+

); } diff --git a/packages/ui/src/Atoms/Vendors/Microsoft.tsx b/packages/ui/src/Atoms/Vendors/Microsoft.tsx new file mode 100644 index 000000000..df71a110a --- /dev/null +++ b/packages/ui/src/Atoms/Vendors/Microsoft.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; + +export function Microsoft(props: ComponentProps<"svg">) { + return ( + + + + + + + ); +} diff --git a/packages/ui/src/Atoms/Vendors/index.ts b/packages/ui/src/Atoms/Vendors/index.ts index 1cb287444..5b9a21224 100644 --- a/packages/ui/src/Atoms/Vendors/index.ts +++ b/packages/ui/src/Atoms/Vendors/index.ts @@ -1,2 +1,3 @@ export { Google } from "./Google"; +export { Microsoft } from "./Microsoft"; export { Slack } from "./Slack"; diff --git a/pkg/coredata/oidc_provider.go b/pkg/coredata/oidc_provider.go new file mode 100644 index 000000000..876306ad2 --- /dev/null +++ b/pkg/coredata/oidc_provider.go @@ -0,0 +1,46 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import "fmt" + +type OIDCProvider string + +const ( + OIDCProviderGoogle OIDCProvider = "GOOGLE" + OIDCProviderMicrosoft OIDCProvider = "MICROSOFT" +) + +func (p OIDCProvider) IsValid() bool { + switch p { + case OIDCProviderGoogle, OIDCProviderMicrosoft: + return true + } + return false +} + +func (p OIDCProvider) String() string { return string(p) } + +func (p *OIDCProvider) UnmarshalText(text []byte) error { + *p = OIDCProvider(text) + if !p.IsValid() { + return fmt.Errorf("%s is not a valid OIDCProvider", string(text)) + } + return nil +} + +func (p OIDCProvider) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} diff --git a/pkg/coredata/oidc_state.go b/pkg/coredata/oidc_state.go index 088ed931d..12457bd17 100644 --- a/pkg/coredata/oidc_state.go +++ b/pkg/coredata/oidc_state.go @@ -24,45 +24,14 @@ import ( "go.gearno.de/kit/pg" ) -type ( - OIDCProvider string - - OIDCState struct { - ID string `db:"id"` - Provider OIDCProvider `db:"provider"` - Nonce string `db:"nonce"` - CodeVerifier string `db:"code_verifier"` - ContinueURL string `db:"continue_url"` - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` - } -) - -const ( - OIDCProviderGoogle OIDCProvider = "GOOGLE" - OIDCProviderMicrosoft OIDCProvider = "MICROSOFT" -) - -func (p OIDCProvider) IsValid() bool { - switch p { - case OIDCProviderGoogle, OIDCProviderMicrosoft: - return true - } - return false -} - -func (p OIDCProvider) String() string { return string(p) } - -func (p *OIDCProvider) UnmarshalText(text []byte) error { - *p = OIDCProvider(text) - if !p.IsValid() { - return fmt.Errorf("%s is not a valid OIDCProvider", string(text)) - } - return nil -} - -func (p OIDCProvider) MarshalText() ([]byte, error) { - return []byte(p.String()), nil +type OIDCState struct { + ID string `db:"id"` + Provider OIDCProvider `db:"provider"` + Nonce string `db:"nonce"` + CodeVerifier string `db:"code_verifier"` + ContinueURL string `db:"continue_url"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` } func (s *OIDCState) Insert(ctx context.Context, conn pg.Conn) error { @@ -125,7 +94,7 @@ func (s *OIDCState) Delete(ctx context.Context, conn pg.Conn) error { return nil } -func DeleteExpiredOIDCStates(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) { +func (s *OIDCState) DeleteExpired(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) { query := `DELETE FROM iam_oidc_states WHERE expires_at < @now` result, err := conn.Exec(ctx, query, pgx.StrictNamedArgs{"now": now}) diff --git a/pkg/iam/oidc/gc.go b/pkg/iam/oidc/gc.go index 38b65d7de..cdec5dbc7 100644 --- a/pkg/iam/oidc/gc.go +++ b/pkg/iam/oidc/gc.go @@ -28,22 +28,40 @@ const ( DefaultGarbageCollectionInterval = 1 * time.Hour ) -type GarbageCollector struct { - pg *pg.Client - interval time.Duration - logger *log.Logger +type ( + GarbageCollector struct { + pg *pg.Client + interval time.Duration + logger *log.Logger + } + + GarbageCollectorOption func(*GarbageCollector) +) + +func WithGarbageCollectionInterval(interval time.Duration) GarbageCollectorOption { + return func(gc *GarbageCollector) { + gc.interval = interval + } } func NewGarbageCollector( - pg *pg.Client, - interval time.Duration, + pgClient *pg.Client, logger *log.Logger, + opts ...GarbageCollectorOption, ) *GarbageCollector { - return &GarbageCollector{ - pg: pg, - interval: interval, - logger: logger.Named("oidc.garbage_collector").With(log.Duration("interval", interval)), + gc := &GarbageCollector{ + pg: pgClient, + interval: DefaultGarbageCollectionInterval, + logger: logger.Named("oidc.garbage_collector"), } + + for _, opt := range opts { + opt(gc) + } + + gc.logger = gc.logger.With(log.Duration("interval", gc.interval)) + + return gc } func (gc *GarbageCollector) Run(ctx context.Context) error { @@ -53,12 +71,15 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { gc.logger.ErrorCtx(ctx, "cannot run initial cleanup", log.Error(err)) } + ticker := time.NewTicker(gc.interval) + defer ticker.Stop() + for { select { case <-ctx.Done(): gc.logger.InfoCtx(ctx, "oidc garbage collector shutting down") return ctx.Err() - case <-time.After(gc.interval): + case <-ticker.C: if err := gc.cleanup(ctx); err != nil { gc.logger.ErrorCtx(ctx, "cannot run periodic cleanup", log.Error(err)) } @@ -72,7 +93,8 @@ func (gc *GarbageCollector) cleanup(ctx context.Context) error { return gc.pg.WithTx( ctx, func(tx pg.Conn) error { - deleted, err := coredata.DeleteExpiredOIDCStates(ctx, tx, now) + var state coredata.OIDCState + deleted, err := state.DeleteExpired(ctx, tx, now) if err != nil { return fmt.Errorf("cannot delete expired oidc states: %w", err) } diff --git a/pkg/iam/oidc/service.go b/pkg/iam/oidc/service.go index 38ca481db..95885824e 100644 --- a/pkg/iam/oidc/service.go +++ b/pkg/iam/oidc/service.go @@ -215,29 +215,25 @@ func NewService( } func (s *Service) Run(ctx context.Context) error { - gc := NewGarbageCollector(s.pg, DefaultGarbageCollectionInterval, s.logger) + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(context.Canceled) - gcCtx, stopGC := context.WithCancel(ctx) - defer stopGC() - - errCh := make(chan error, 1) - go func() { - errCh <- gc.Run(gcCtx) - }() - - select { - case <-ctx.Done(): - stopGC() - <-errCh - return ctx.Err() - case err := <-errCh: - if err != nil { - s.logger.ErrorCtx(ctx, "oidc garbage collector failed", log.Error(err)) - return err + gcCtx, stopGC := context.WithCancel(context.WithoutCancel(ctx)) + gc := NewGarbageCollector(s.pg, s.logger) + wg.Go(func() { + if err := gc.Run(gcCtx); err != nil { + cancel(fmt.Errorf("oidc garbage collector crashed: %w", err)) } + }) - return nil - } + <-ctx.Done() + + stopGC() + + wg.Wait() + + return context.Cause(ctx) } func (s *Service) IsProviderEnabled(provider coredata.OIDCProvider) bool { diff --git a/pkg/iam/saml/gc.go b/pkg/iam/saml/gc.go index 3bc333407..d66a75226 100644 --- a/pkg/iam/saml/gc.go +++ b/pkg/iam/saml/gc.go @@ -34,20 +34,36 @@ type ( interval time.Duration logger *log.Logger } + + GarbageCollectorOption func(*GarbageCollector) ) -func NewGarbageCollector( - pg *pg.Client, - interval time.Duration, - logger *log.Logger, -) *GarbageCollector { - return &GarbageCollector{ - pg: pg, - interval: interval, - logger: logger.Named("saml.garbage_collector").With(log.Duration("interval", interval)), +func WithGarbageCollectionInterval(interval time.Duration) GarbageCollectorOption { + return func(gc *GarbageCollector) { + gc.interval = interval } } +func NewGarbageCollector( + pgClient *pg.Client, + logger *log.Logger, + opts ...GarbageCollectorOption, +) *GarbageCollector { + gc := &GarbageCollector{ + pg: pgClient, + interval: DefaultGarbageCollectionInterval, + logger: logger.Named("saml.garbage_collector"), + } + + for _, opt := range opts { + opt(gc) + } + + gc.logger = gc.logger.With(log.Duration("interval", gc.interval)) + + return gc +} + func (gc *GarbageCollector) Run(ctx context.Context) error { gc.logger.InfoCtx(ctx, "saml garbage collector starting") @@ -55,12 +71,15 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { gc.logger.ErrorCtx(ctx, "cannot run initial cleanup", log.Error(err)) } + ticker := time.NewTicker(gc.interval) + defer ticker.Stop() + for { select { case <-ctx.Done(): gc.logger.InfoCtx(ctx, "saml garbage collector shutting down") return ctx.Err() - case <-time.After(gc.interval): + case <-ticker.C: if err := gc.cleanup(ctx); err != nil { gc.logger.ErrorCtx(ctx, "cannot run periodic cleanup", log.Error(err)) } diff --git a/pkg/iam/saml/service.go b/pkg/iam/saml/service.go index bc31a3b21..ddbe98a15 100644 --- a/pkg/iam/saml/service.go +++ b/pkg/iam/saml/service.go @@ -24,6 +24,7 @@ import ( "fmt" "net/url" "strings" + "sync" "time" "github.com/crewjam/saml" @@ -70,29 +71,25 @@ func NewService( } func (s *Service) Run(ctx context.Context) error { - gc := NewGarbageCollector(s.pg, DefaultGarbageCollectionInterval, s.logger) + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(context.Canceled) - gcCtx, stopGC := context.WithCancel(ctx) - defer stopGC() - - errCh := make(chan error, 1) - go func() { - errCh <- gc.Run(gcCtx) - }() - - select { - case <-ctx.Done(): - stopGC() - <-errCh - return ctx.Err() - case err := <-errCh: - if err != nil { - s.logger.ErrorCtx(ctx, "saml garbage collector failed", log.Error(err)) - return err + gcCtx, stopGC := context.WithCancel(context.WithoutCancel(ctx)) + gc := NewGarbageCollector(s.pg, s.logger) + wg.Go(func() { + if err := gc.Run(gcCtx); err != nil { + cancel(fmt.Errorf("saml garbage collector crashed: %w", err)) } + }) - return nil - } + <-ctx.Done() + + stopGC() + + wg.Wait() + + return context.Cause(ctx) } func (s *Service) GenerateSpMetadata() ([]byte, error) { diff --git a/pkg/iam/saml_domain_verifier.go b/pkg/iam/saml_domain_verifier.go index 9d6e774f1..89ae5f0f7 100644 --- a/pkg/iam/saml_domain_verifier.go +++ b/pkg/iam/saml_domain_verifier.go @@ -67,14 +67,18 @@ func NewSAMLDomainVerifier( func (v *SAMLDomainVerifier) Run(ctx context.Context) error { v.logger.InfoCtx(ctx, "starting", log.Duration("interval", v.interval)) - for { - v.runOnce(ctx) + v.runOnce(ctx) + ticker := time.NewTicker(v.interval) + defer ticker.Stop() + + for { select { case <-ctx.Done(): v.logger.InfoCtx(ctx, "shutting down") return ctx.Err() - case <-time.After(v.interval): + case <-ticker.C: + v.runOnce(ctx) } } } diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 5f67e9014..d997d5f48 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -5,6 +5,7 @@ import ( "crypto/rsa" "crypto/x509" "fmt" + "sync" "time" "github.com/prometheus/client_golang/prometheus" @@ -21,7 +22,6 @@ import ( "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/iam/saml" "go.probo.inc/probo/pkg/iam/scim" - "golang.org/x/sync/errgroup" ) type ( @@ -175,14 +175,48 @@ func NewService( } func (s *Service) Run(ctx context.Context) error { - g, ctx := errgroup.WithContext(ctx) + wg := sync.WaitGroup{} + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(context.Canceled) - g.Go(func() error { return s.SAMLService.Run(ctx) }) - g.Go(func() error { return s.OIDCService.Run(ctx) }) - g.Go(func() error { return s.samlDomainVerifier.Run(ctx) }) - g.Go(func() error { return s.SCIMService.Run(ctx) }) + samlCtx, stopSAML := context.WithCancel(context.WithoutCancel(ctx)) + wg.Go(func() { + if err := s.SAMLService.Run(samlCtx); err != nil { + cancel(fmt.Errorf("saml service crashed: %w", err)) + } + }) - return g.Wait() + oidcCtx, stopOIDC := context.WithCancel(context.WithoutCancel(ctx)) + wg.Go(func() { + if err := s.OIDCService.Run(oidcCtx); err != nil { + cancel(fmt.Errorf("oidc service crashed: %w", err)) + } + }) + + domainVerifierCtx, stopDomainVerifier := context.WithCancel(context.WithoutCancel(ctx)) + wg.Go(func() { + if err := s.samlDomainVerifier.Run(domainVerifierCtx); err != nil { + cancel(fmt.Errorf("saml domain verifier crashed: %w", err)) + } + }) + + scimCtx, stopSCIM := context.WithCancel(context.WithoutCancel(ctx)) + wg.Go(func() { + if err := s.SCIMService.Run(scimCtx); err != nil { + cancel(fmt.Errorf("scim service crashed: %w", err)) + } + }) + + <-ctx.Done() + + stopSAML() + stopOIDC() + stopDomainVerifier() + stopSCIM() + + wg.Wait() + + return context.Cause(ctx) } func (s *Service) GetMembership(ctx context.Context, membershipID gid.GID) (*coredata.Membership, error) {