From 27dc61ef82593540eb6b528c06b7a039ada278e3 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 15 Jul 2026 10:58:59 +0200 Subject: [PATCH] Add compliance portal visitor OAuth service Handle OAuth initiation, callback exchange, and session creation for anonymous trust center visitors through the portal package. Signed-off-by: Bryan Frimin --- .../visitor/compliance_framework_service.go | 25 ++ pkg/complianceportal/visitor/errors.go | 2 + pkg/complianceportal/visitor/oauth.go | 213 ++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 pkg/complianceportal/visitor/oauth.go diff --git a/pkg/complianceportal/visitor/compliance_framework_service.go b/pkg/complianceportal/visitor/compliance_framework_service.go index 177f8ea1f..594bdeb79 100644 --- a/pkg/complianceportal/visitor/compliance_framework_service.go +++ b/pkg/complianceportal/visitor/compliance_framework_service.go @@ -30,6 +30,31 @@ import ( "go.probo.inc/probo/pkg/page" ) +func (s *Service) GetComplianceFramework( + ctx context.Context, + scope coredata.Scoper, + complianceFrameworkID gid.GID, +) (*coredata.ComplianceFramework, error) { + cf := &coredata.ComplianceFramework{} + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + err := cf.LoadByID(ctx, conn, scope, complianceFrameworkID) + if err != nil { + return fmt.Errorf("cannot load compliance framework: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return cf, nil +} + func (s *Service) ListComplianceFrameworksByPortalID( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/complianceportal/visitor/errors.go b/pkg/complianceportal/visitor/errors.go index dcbcb25fa..e07cbbfdf 100644 --- a/pkg/complianceportal/visitor/errors.go +++ b/pkg/complianceportal/visitor/errors.go @@ -23,6 +23,8 @@ package visitor import "errors" var ( + ErrOAuthStateNotFound = errors.New("oauth state not found") + ErrOAuthStateExpired = errors.New("oauth state expired") ErrPageNotFound = errors.New("page not found") ErrMembershipNotFound = errors.New("membership not found") ErrUserNotFound = errors.New("user not found") diff --git a/pkg/complianceportal/visitor/oauth.go b/pkg/complianceportal/visitor/oauth.go new file mode 100644 index 000000000..9ca272cfc --- /dev/null +++ b/pkg/complianceportal/visitor/oauth.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 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 visitor + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" +) + +const oauthStateTTL = 15 * time.Minute + +type OAuthState struct { + ContinueURL string + CodeVerifier string + Nonce string +} + +func (s *Service) InitiateOAuthAuthorizeURL( + ctx context.Context, + authorizeURL string, + clientID string, + redirectURI string, + scopes []string, + continueURL string, +) (string, error) { + state, err := generateRandomString(32) + if err != nil { + return "", fmt.Errorf("cannot generate state: %w", err) + } + + nonce, err := generateRandomString(32) + if err != nil { + return "", fmt.Errorf("cannot generate nonce: %w", err) + } + + codeVerifier, err := generateRandomString(64) + if err != nil { + return "", fmt.Errorf("cannot generate code verifier: %w", err) + } + + err = s.persistOAuthState( + ctx, + state, + continueURL, + oauthSession{ + CodeVerifier: codeVerifier, + Nonce: nonce, + }, + ) + if err != nil { + return "", err + } + + authorizeURL, err = buildAuthorizeURL( + authorizeURL, + clientID, + redirectURI, + scopes, + state, + nonce, + computeCodeChallenge(codeVerifier), + ) + if err != nil { + return "", err + } + + return authorizeURL, nil +} + +func (s *Service) ConsumeOAuthState(ctx context.Context, stateID string) (OAuthState, error) { + var oidcState coredata.OIDCState + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := oidcState.LoadByIDForUpdate(ctx, tx, stateID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrOAuthStateNotFound + } + + return fmt.Errorf("cannot load oidc state: %w", err) + } + + if err := oidcState.Delete(ctx, tx); err != nil { + return fmt.Errorf("cannot delete oidc state: %w", err) + } + + return nil + }, + ) + if err != nil { + return OAuthState{}, err + } + + if time.Now().After(oidcState.ExpiresAt) { + return OAuthState{}, ErrOAuthStateExpired + } + + if oidcState.Provider != coredata.OIDCProviderCompliancePortal { + return OAuthState{}, ErrOAuthStateNotFound + } + + return OAuthState{ + ContinueURL: oidcState.ContinueURL, + CodeVerifier: oidcState.CodeVerifier, + Nonce: oidcState.Nonce, + }, nil +} + +type oauthSession struct { + CodeVerifier string + Nonce string +} + +func (s *Service) persistOAuthState( + ctx context.Context, + stateID string, + continueURL string, + session oauthSession, +) error { + now := time.Now() + oidcState := &coredata.OIDCState{ + ID: stateID, + Provider: coredata.OIDCProviderCompliancePortal, + Nonce: session.Nonce, + CodeVerifier: session.CodeVerifier, + ContinueURL: continueURL, + CreatedAt: now, + ExpiresAt: now.Add(oauthStateTTL), + } + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + if err := oidcState.Insert(ctx, tx); err != nil { + return fmt.Errorf("cannot insert oidc state: %w", err) + } + + return nil + }, + ) + if err != nil { + return fmt.Errorf("cannot persist oauth state: %w", err) + } + + return nil +} + +func buildAuthorizeURL( + authorizeURL string, + clientID string, + redirectURI string, + scopes []string, + state string, + nonce string, + codeChallenge string, +) (string, error) { + u, err := url.Parse(authorizeURL) + if err != nil { + return "", fmt.Errorf("cannot parse authorize URL: %w", err) + } + + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("scope", strings.Join(scopes, " ")) + q.Set("state", state) + q.Set("nonce", nonce) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func computeCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +func generateRandomString(length int) (string, error) { + b := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("cannot generate random bytes: %w", err) + } + + return base64.RawURLEncoding.EncodeToString(b), nil +}