Files
probo/pkg/coredata/electronic_signature.go
Bryan Frimin 11770b4058 Add OAuth2/OpenID Connect authorization server
Implement a full OAuth2 2.0 and OpenID Connect 1.0 authorization
server with support for authorization code flow (with PKCE),
refresh token rotation, device authorization grant, dynamic
client registration, token introspection, and token revocation.

Includes database schema, coredata layer, service logic, HTTP
handlers, OIDC discovery endpoint, and JWKS publishing.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-04-19 12:00:53 +02:00

377 lines
12 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 (
"context"
"errors"
"fmt"
"maps"
"strings"
"time"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/crypto/hash"
"go.probo.inc/probo/pkg/gid"
)
type ElectronicSignature struct {
ID gid.GID `db:"id"`
TenantID gid.TenantID `db:"tenant_id"`
OrganizationID gid.GID `db:"organization_id"`
Status ElectronicSignatureStatus `db:"status"`
DocumentType ElectronicSignatureDocumentType `db:"document_type"`
DocumentName *string `db:"document_name"`
FileID gid.GID `db:"file_id"`
SignerEmail string `db:"signer_email"`
ConsentText string `db:"consent_text"`
SignerFullName *string `db:"signer_full_name"`
SignerIPAddress *string `db:"signer_ip_address"`
SignerUserAgent *string `db:"signer_user_agent"`
FileHash *string `db:"file_hash"`
Seal *string `db:"seal"`
SealVersion int `db:"seal_version"`
TSAToken []byte `db:"tsa_token"`
SignedAt *time.Time `db:"signed_at"`
CertificateFileID *gid.GID `db:"certificate_file_id"`
CertificateProcessingStartedAt *time.Time `db:"certificate_processing_started_at"`
AttemptCount int `db:"attempt_count"`
MaxAttempts int `db:"max_attempts"`
LastAttemptedAt *time.Time `db:"last_attempted_at"`
LastError *string `db:"last_error"`
ProcessingStartedAt *time.Time `db:"processing_started_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (es *ElectronicSignature) NewEvent(
eventType ElectronicSignatureEventType,
eventSource ElectronicSignatureEventSource,
) ElectronicSignatureEvent {
now := time.Now()
return ElectronicSignatureEvent{
ID: gid.New(es.ID.TenantID(), ElectronicSignatureEventEntityType),
ElectronicSignatureID: es.ID,
EventType: eventType,
EventSource: eventSource,
ActorEmail: es.SignerEmail,
ActorIPAddress: ref.UnrefOrZero(es.SignerIPAddress),
ActorUserAgent: ref.UnrefOrZero(es.SignerUserAgent),
OccurredAt: now,
CreatedAt: now,
}
}
func (es *ElectronicSignature) Insert(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
INSERT INTO electronic_signatures (
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, seal_version, attempt_count, max_attempts,
created_at, updated_at
) VALUES (
@id, @tenant_id, @organization_id, @status, @document_type, @document_name, @file_id,
@signer_email, @consent_text, @seal_version, @attempt_count, @max_attempts,
@created_at, @updated_at
)
`
args := pgx.StrictNamedArgs{
"id": es.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": es.OrganizationID,
"status": es.Status,
"document_type": es.DocumentType,
"document_name": es.DocumentName,
"file_id": es.FileID,
"signer_email": es.SignerEmail,
"consent_text": es.ConsentText,
"seal_version": es.SealVersion,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"created_at": es.CreatedAt,
"updated_at": es.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) Update(
ctx context.Context,
conn pg.Tx,
scope Scoper,
) error {
q := `
UPDATE electronic_signatures SET
status = @status,
signer_full_name = @signer_full_name,
signer_ip_address = @signer_ip_address,
signer_user_agent = @signer_user_agent,
file_hash = @file_hash,
seal = @seal,
seal_version = @seal_version,
tsa_token = @tsa_token,
signed_at = @signed_at,
certificate_file_id = @certificate_file_id,
certificate_processing_started_at = @certificate_processing_started_at,
attempt_count = @attempt_count,
max_attempts = @max_attempts,
last_attempted_at = @last_attempted_at,
last_error = @last_error,
processing_started_at = @processing_started_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": es.ID,
"status": es.Status,
"signer_full_name": es.SignerFullName,
"signer_ip_address": es.SignerIPAddress,
"signer_user_agent": es.SignerUserAgent,
"file_hash": es.FileHash,
"seal": es.Seal,
"seal_version": es.SealVersion,
"tsa_token": es.TSAToken,
"signed_at": es.SignedAt,
"certificate_file_id": es.CertificateFileID,
"certificate_processing_started_at": es.CertificateProcessingStartedAt,
"attempt_count": es.AttemptCount,
"max_attempts": es.MaxAttempts,
"last_attempted_at": es.LastAttemptedAt,
"last_error": es.LastError,
"processing_started_at": es.ProcessingStartedAt,
"updated_at": es.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update electronic signature: %w", err)
}
return nil
}
func (es *ElectronicSignature) LoadByID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
id gid.GID,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE %s AND id = @id
LIMIT 1
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": id}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query electronic signature: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextAcceptedForUpdateSkipLocked(
ctx context.Context,
conn pg.Tx,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'ACCEPTED' AND attempt_count < max_attempts
ORDER BY updated_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query accepted signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func (es *ElectronicSignature) LoadNextCompletedWithoutCertificateForUpdate(
ctx context.Context,
conn pg.Tx,
) error {
q := `
SELECT
id, tenant_id, organization_id, status, document_type, document_name, file_id,
signer_email, consent_text, signer_full_name, signer_ip_address,
signer_user_agent, file_hash, seal, seal_version, tsa_token, signed_at,
certificate_file_id, certificate_processing_started_at,
attempt_count, max_attempts, last_attempted_at, last_error,
processing_started_at, created_at, updated_at
FROM electronic_signatures
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NULL
AND attempt_count < max_attempts
ORDER BY signed_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query completed signatures: %w", err)
}
sig, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[ElectronicSignature])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect electronic signature: %w", err)
}
*es = sig
return nil
}
func ResetStaleProcessingSignatures(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET status = 'ACCEPTED', processing_started_at = NULL, updated_at = NOW()
WHERE status = 'PROCESSING'
AND processing_started_at < NOW() - $1::interval
`
_, err := conn.Exec(ctx, q, staleAfter)
if err != nil {
return fmt.Errorf("cannot reset stale processing signatures: %w", err)
}
return nil
}
func (es *ElectronicSignature) ComputeSeal(version int) (string, error) {
switch version {
case 1:
return es.computeSealV1()
default:
return "", fmt.Errorf("unsupported seal version %d", version)
}
}
func (es *ElectronicSignature) computeSealV1() (string, error) {
if es.SignedAt == nil {
return "", fmt.Errorf("signed_at must not be nil")
}
fields := []string{
es.ID.String(),
es.OrganizationID.String(),
es.DocumentType.String(),
es.FileID.String(),
ref.UnrefOrZero(es.FileHash),
ref.UnrefOrZero(es.SignerFullName),
strings.ToLower(es.SignerEmail),
ref.UnrefOrZero(es.SignerIPAddress),
ref.UnrefOrZero(es.SignerUserAgent),
es.ConsentText,
es.SignedAt.UTC().Truncate(time.Microsecond).Format(time.RFC3339Nano),
}
for i, f := range fields {
if f == "" {
return "", fmt.Errorf("seal field %d must not be empty", i)
}
if strings.Contains(f, "\n") {
return "", fmt.Errorf("seal field %d must not contain newline", i)
}
}
input := strings.Join(fields, "\n")
return hash.SHA256HexString(input), nil
}
func ResetStaleCertificateProcessing(
ctx context.Context,
conn pg.Querier,
staleAfter time.Duration,
) error {
q := `
UPDATE electronic_signatures
SET certificate_processing_started_at = NULL, updated_at = NOW()
WHERE status = 'COMPLETED'
AND certificate_file_id IS NULL
AND certificate_processing_started_at IS NOT NULL
AND certificate_processing_started_at < NOW() - $1::interval
AND attempt_count < max_attempts
`
_, err := conn.Exec(ctx, q, staleAfter)
if err != nil {
return fmt.Errorf("cannot reset stale certificate processing: %w", err)
}
return nil
}