feat: sync Telegram Login OIDC provider
This commit is contained in:
parent
30774f8c39
commit
ebead9e98c
63 changed files with 11374 additions and 37 deletions
186
internal/app/telegramlogin/keyfiles.go
Normal file
186
internal/app/telegramlogin/keyfiles.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package telegramlogin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTelegramLoginManifestBytes = 1 << 20
|
||||
maxTelegramLoginKeyBytes = 256 << 10
|
||||
)
|
||||
|
||||
type signingKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Keys []signingKeyManifestEntry `json:"keys"`
|
||||
}
|
||||
|
||||
type signingKeyManifestEntry struct {
|
||||
Algorithm domain.TelegramLoginSigningAlgorithm `json:"algorithm"`
|
||||
KeyID string `json:"kid,omitempty"`
|
||||
PrivateKeyFile string `json:"private_key_file"`
|
||||
Active bool `json:"active"`
|
||||
PublishUntil string `json:"publish_until,omitempty"`
|
||||
}
|
||||
|
||||
// LoadSigningKeyRing reads a versioned manifest and private PEM/JWK files.
|
||||
// Relative key paths are resolved against the manifest directory. The caller
|
||||
// should atomically replace files and rebuild/swap the ring when rotating.
|
||||
func LoadSigningKeyRing(path string, now func() time.Time) (*SigningKeyRing, error) {
|
||||
var manifest signingKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login signing manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || len(manifest.Keys) == 0 || len(manifest.Keys) > 32 {
|
||||
return nil, errors.New("telegram login signing manifest has invalid version or key count")
|
||||
}
|
||||
baseDir := filepath.Dir(path)
|
||||
materials := make([]SigningKeyMaterial, 0, len(manifest.Keys))
|
||||
for index, entry := range manifest.Keys {
|
||||
keyPath := strings.TrimSpace(entry.PrivateKeyFile)
|
||||
if !entry.Algorithm.Valid() || keyPath == "" {
|
||||
return nil, fmt.Errorf("telegram login signing manifest key %d is invalid", index)
|
||||
}
|
||||
if !filepath.IsAbs(keyPath) {
|
||||
keyPath = filepath.Join(baseDir, keyPath)
|
||||
}
|
||||
data, err := readBoundedFile(keyPath, maxTelegramLoginKeyBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var parsed jwk.Key
|
||||
if len(bytes.TrimSpace(data)) > 0 && bytes.TrimSpace(data)[0] == '{' {
|
||||
parsed, err = jwk.ParseKey(data)
|
||||
} else {
|
||||
parsed, err = jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var raw any
|
||||
if err := jwk.Export(parsed, &raw); err != nil {
|
||||
return nil, fmt.Errorf("export telegram login signing key %d: %w", index, err)
|
||||
}
|
||||
var publishUntil time.Time
|
||||
if entry.PublishUntil != "" {
|
||||
publishUntil, err = time.Parse(time.RFC3339, entry.PublishUntil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse telegram login signing key %d publish_until: %w", index, err)
|
||||
}
|
||||
}
|
||||
if entry.Active && !publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("active telegram login signing key %d must not set publish_until", index)
|
||||
}
|
||||
if !entry.Active && publishUntil.IsZero() {
|
||||
return nil, fmt.Errorf("retiring telegram login signing key %d requires publish_until", index)
|
||||
}
|
||||
materials = append(materials, SigningKeyMaterial{
|
||||
Algorithm: entry.Algorithm, KeyID: entry.KeyID, PrivateKey: raw,
|
||||
Active: entry.Active, PublishUntil: publishUntil,
|
||||
})
|
||||
}
|
||||
return NewSigningKeyRing(materials, now)
|
||||
}
|
||||
|
||||
type codeKeyManifest struct {
|
||||
Version int `json:"version"`
|
||||
Active string `json:"active"`
|
||||
Keys map[string]string `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadCodeSealer(path string) (*CodeSealer, error) {
|
||||
var manifest codeKeyManifest
|
||||
if err := readStrictJSONFile(path, maxTelegramLoginManifestBytes, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("load telegram login code-key manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 1 || manifest.Active == "" || len(manifest.Keys) == 0 || len(manifest.Keys) > 16 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid version or key count")
|
||||
}
|
||||
keys := make(map[string][]byte, len(manifest.Keys))
|
||||
for keyID, encoded := range manifest.Keys {
|
||||
if strings.TrimSpace(keyID) == "" || keyID != strings.TrimSpace(keyID) || len(keyID) > 128 {
|
||||
return nil, errors.New("telegram login code-key manifest has invalid key id")
|
||||
}
|
||||
raw, err := decodeBase64Key(encoded)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, fmt.Errorf("telegram login code-key %q must be 32 base64-encoded bytes", keyID)
|
||||
}
|
||||
keys[keyID] = raw
|
||||
}
|
||||
return NewCodeSealer(manifest.Active, keys)
|
||||
}
|
||||
|
||||
func LoadClientSecretPepper(path string) ([]byte, error) {
|
||||
data, err := readBoundedFile(path, 4096)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read telegram login client-secret pepper: %w", err)
|
||||
}
|
||||
raw, err := decodeBase64Key(strings.TrimSpace(string(data)))
|
||||
if err != nil || len(raw) != 32 {
|
||||
return nil, errors.New("telegram login client-secret pepper must be 32 base64-encoded bytes")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodeBase64Key(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding,
|
||||
} {
|
||||
if raw, err := encoding.DecodeString(value); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("invalid base64")
|
||||
}
|
||||
|
||||
func readStrictJSONFile(path string, maxBytes int64, target any) error {
|
||||
data, err := readBoundedFile(path, maxBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBoundedFile(path string, maxBytes int64) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
|
||||
return nil, errors.New("invalid file path or size bound")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() > maxBytes {
|
||||
return nil, errors.New("file is not regular or exceeds size bound")
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(file, maxBytes+1))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue