From f195d0f21480495f6d708837b155ffed0f187fa0 Mon Sep 17 00:00:00 2001 From: itzjb Date: Sun, 9 Aug 2026 06:26:58 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20YouTube=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=EA=B3=BC=20=EC=86=A1=EC=B6=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자별 RTMP 송출 대상을 도입하기 위한 인증·저장 계층이다(#83). 송출 대상을 서버 전역 환경변수 하나로 고정하던 구조를 사용자별로 바꾸는 작업의 전반부이며, 방송 라이프사이클 제어와 송출 시작·중지 배선은 후속 작업으로 남긴다. streaming_accounts 테이블을 신설한다. 로그인 신원인 oauth_accounts와 분리한 것은 로그인에 쓰는 Google 계정과 송출할 채널의 계정이 다를 수 있기 때문이다. provider CHECK에 chzzk 자리를 미리 넣어 두어 나중에 제약을 ALTER 하지 않도록 했고, GORM AutoMigrate 목록과 versioned SQL 양쪽을 함께 갱신했다. 연결은 두 단계다. POST /auth/youtube/connect 가 Bearer 인증으로 사용자를 확인하고 state 를 발급한 뒤 authorize URL 을 반환하면, 클라이언트가 그 URL 로 이동하고 콜백에서 state 로 사용자를 복원한다. 콜백은 브라우저 리다이렉트로 도착해 Authorization 헤더가 없으므로 state 가 유일한 사용자 바인딩 수단이다. state 는 1회용이며 TTL 10분이다. 리프레시 토큰은 기존 ProviderTokenCipher 로 암호화해 저장하고 평문은 남기지 않는다. Apple 로그인이 비활성인 환경에서도 암호화기가 생성되도록 배선을 고쳤다. 액세스 토큰 갱신은 사용자 단위로 직렬화해 다중 세션에서 중복 갱신이 발생하지 않게 했다. 구현 전 수동 E2E 로 확인한 사실을 반영했다. 토큰 응답의 refresh_token_expires_in 이 7일이어서 만료 시각을 추적하는 컬럼을 두었고, 콜백에 문서에 없는 iss 파라미터가 함께 오므로 명시한 파라미터만 읽는다. 인가 코드에 슬래시가 포함되어 교환 시 form 인코딩이 필요하고, 채널이 없는 계정으로 연결을 시도하면 응답의 items 가 비어 오므로 이를 오류로 다룬다. --- .env.example | 7 + cmd/server/main.go | 36 +- internal/auth/migrate.go | 1 + internal/auth/oauth_state.go | 73 +++ internal/auth/oauth_state_test.go | 68 +++ internal/auth/provider_token_cipher.go | 2 +- internal/auth/streaming_account.go | 148 ++++++ .../auth/streaming_account_postgres_test.go | 138 ++++++ internal/auth/token_http.go | 24 +- internal/auth/youtube_oauth.go | 419 ++++++++++++++++ internal/auth/youtube_oauth_http.go | 86 ++++ internal/auth/youtube_oauth_http_test.go | 147 ++++++ internal/auth/youtube_oauth_test.go | 457 ++++++++++++++++++ .../000003_create_streaming_accounts.down.sql | 5 + .../000003_create_streaming_accounts.up.sql | 33 ++ 15 files changed, 1636 insertions(+), 8 deletions(-) create mode 100644 internal/auth/oauth_state.go create mode 100644 internal/auth/oauth_state_test.go create mode 100644 internal/auth/streaming_account.go create mode 100644 internal/auth/streaming_account_postgres_test.go create mode 100644 internal/auth/youtube_oauth.go create mode 100644 internal/auth/youtube_oauth_http.go create mode 100644 internal/auth/youtube_oauth_http_test.go create mode 100644 internal/auth/youtube_oauth_test.go create mode 100644 internal/database/migration/sql/000003_create_streaming_accounts.down.sql create mode 100644 internal/database/migration/sql/000003_create_streaming_accounts.up.sql diff --git a/.env.example b/.env.example index b092f12..3bc4405 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,13 @@ APPLE_PRIVATE_KEY_PATH= # openssl rand -base64 32 로 생성한 32바이트 AES-256 키입니다. 절대로 저장소에 커밋하지 마세요. AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64= +# YouTube 송출 연동 (로그인용 Google OAuth와 별개의 전용 GCP 프로젝트/클라이언트) +# 셋 다 설정해야 활성화되며, 활성 시 AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64도 필요합니다. +# REDIRECT_URI는 GCP 콘솔에 등록된 값과 정확히 일치해야 합니다(예: http://localhost:8000/auth/youtube/callback). +YOUTUBE_OAUTH_CLIENT_ID= +YOUTUBE_OAUTH_CLIENT_SECRET= +YOUTUBE_OAUTH_REDIRECT_URI= + # Email/password authentication (SMTP + Redis) # AUTH_EMAIL_SMTP_HOST가 비어 있으면 이메일 회원가입 API는 비활성화됩니다. # 587 포트는 STARTTLS=true, 465 포트는 IMPLICIT_TLS=true 및 STARTTLS=false가 일반적입니다. diff --git a/cmd/server/main.go b/cmd/server/main.go index 14284ca..49b30c1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -295,6 +295,11 @@ func main() { logger.Error("create account withdrawal service failed", "error", err) os.Exit(2) } + youtubeOAuthConfig, err := auth.LoadYouTubeOAuthConfigFromEnv() + if err != nil { + logger.Error("invalid YouTube OAuth configuration", "error", err) + os.Exit(2) + } logger.Info( "authentication token service ready", "access_ttl", tokenConfig.AccessTTL, @@ -305,9 +310,38 @@ func main() { "google_oauth_enabled", googleOAuthConfig.Enabled(), "apple_oauth_enabled", appleOAuthConfig.Enabled(), "email_auth_enabled", emailAuthConfig.Enabled(), + "youtube_streaming_enabled", youtubeOAuthConfig.Enabled(), ) userStatusChecker := auth.NewGormUserStatusChecker(databaseConnection.DB) + // YouTube 송출 연동. 암호화 키(cipher)는 Apple 활성 시 위에서 이미 + // 생성됐을 수 있으므로 없을 때만 만든다 — Apple 없이 YouTube만 켠 + // 배포에서도 refresh token 암호화가 성립해야 한다. + var youtubeConnect *auth.YouTubeConnectService + if youtubeOAuthConfig.Enabled() { + if providerTokenCipher == nil { + providerTokenCipher, err = auth.NewProviderTokenCipherFromBase64(os.Getenv("AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64")) + if err != nil { + logger.Error("invalid provider token encryption configuration", "error", err) + os.Exit(2) + } + } + youtubeOAuthClient, err := auth.NewYouTubeOAuthClient(youtubeOAuthConfig) + if err != nil { + logger.Error("create YouTube OAuth client failed", "error", err) + os.Exit(2) + } + youtubeConnect, err = auth.NewYouTubeConnectService( + youtubeOAuthClient, + auth.NewGormStreamingAccountStore(databaseConnection.DB), + userStatusChecker, + providerTokenCipher, + ) + if err != nil { + logger.Error("create YouTube connect service failed", "error", err) + os.Exit(2) + } + } // INNOLIVE_REQUIRE_SESSION_AUTH=false is the explicit local-development // escape hatch (loud warning above). Extend it to user auth as well so // tokenless bench tooling (pion-load) keeps working against dev servers; @@ -373,7 +407,7 @@ func main() { httpServer := &http.Server{ Addr: cfg.HTTPAddr, - Handler: auth.MountAuthHTTPWithServices(application.Handler(), tokenService, googleLogin, appleLogin, emailLogin, withdrawal, logger, originConfig), + Handler: auth.MountAuthHTTPWithServices(application.Handler(), tokenService, googleLogin, appleLogin, emailLogin, withdrawal, logger, originConfig, youtubeConnect), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } diff --git a/internal/auth/migrate.go b/internal/auth/migrate.go index aeaa677..1b091e8 100644 --- a/internal/auth/migrate.go +++ b/internal/auth/migrate.go @@ -18,6 +18,7 @@ func AutoMigrate(ctx context.Context, db *gorm.DB) error { &OAuthAccount{}, &EmailAccount{}, &RefreshSession{}, + &StreamingAccount{}, ); err != nil { return fmt.Errorf("auto migrate authentication schema: %w", err) } diff --git a/internal/auth/oauth_state.go b/internal/auth/oauth_state.go new file mode 100644 index 0000000..e0ab765 --- /dev/null +++ b/internal/auth/oauth_state.go @@ -0,0 +1,73 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" + + "github.com/google/uuid" +) + +// oauthStateStore는 OAuth authorize 왕복의 state를 보관한다. 콜백은 브라우저 +// 리다이렉트로 도착해 Authorization 헤더를 실을 수 없으므로, state가 콜백 +// 요청을 시작 사용자와 잇는 유일한 바인딩이다. 단일 인스턴스 배포 전제의 +// 인메모리 저장이며, 서버 재시작 시 진행 중이던 연결 플로우만 무효화된다 +// (사용자가 연결을 다시 시작하면 복구된다). +type oauthStateStore struct { + mu sync.Mutex + entries map[string]oauthStateEntry + ttl time.Duration + now func() time.Time +} + +type oauthStateEntry struct { + userID uuid.UUID + expiresAt time.Time +} + +func newOAuthStateStore(ttl time.Duration) *oauthStateStore { + return &oauthStateStore{ + entries: make(map[string]oauthStateEntry), + ttl: ttl, + now: func() time.Time { return time.Now().UTC() }, + } +} + +// Issue는 사용자에 바인딩된 1회용 state를 발급한다. +func (s *oauthStateStore) Issue(userID uuid.UUID) (string, error) { + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("generate OAuth state: %w", err) + } + state := hex.EncodeToString(raw) + now := s.now() + s.mu.Lock() + defer s.mu.Unlock() + // 발급 시점마다 만료 항목을 청소한다. 진행 중인 연결 플로우 수만큼만 + // 쌓이는 맵이라 순회 비용은 무시할 수 있다. + for key, entry := range s.entries { + if !now.Before(entry.expiresAt) { + delete(s.entries, key) + } + } + s.entries[state] = oauthStateEntry{userID: userID, expiresAt: now.Add(s.ttl)} + return state, nil +} + +// Consume은 state를 검증하고 바인딩된 사용자를 돌려준다. 성공·실패와 무관하게 +// 조회된 state는 즉시 제거된다(1회용 — 재사용은 CSRF 재시도 신호다). +func (s *oauthStateStore) Consume(state string) (uuid.UUID, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.entries[state] + if !ok { + return uuid.Nil, false + } + delete(s.entries, state) + if !s.now().Before(entry.expiresAt) { + return uuid.Nil, false + } + return entry.userID, true +} diff --git a/internal/auth/oauth_state_test.go b/internal/auth/oauth_state_test.go new file mode 100644 index 0000000..833e457 --- /dev/null +++ b/internal/auth/oauth_state_test.go @@ -0,0 +1,68 @@ +package auth + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestOAuthStateIssueAndConsumeOnce(t *testing.T) { + store := newOAuthStateStore(time.Minute) + userID := uuid.New() + state, err := store.Issue(userID) + if err != nil { + t.Fatal(err) + } + if len(state) != 32 { + t.Fatalf("state length = %d, want 32 hex chars", len(state)) + } + + got, ok := store.Consume(state) + if !ok || got != userID { + t.Fatalf("Consume = (%v, %v), want (%v, true)", got, ok, userID) + } + // 1회용: 같은 state 재사용은 실패해야 한다(CSRF 재시도 신호). + if _, ok := store.Consume(state); ok { + t.Fatal("state must be single-use") + } +} + +func TestOAuthStateUnknownAndExpired(t *testing.T) { + store := newOAuthStateStore(time.Minute) + if _, ok := store.Consume("never-issued"); ok { + t.Fatal("unknown state must not consume") + } + + now := time.Now().UTC() + store.now = func() time.Time { return now } + state, err := store.Issue(uuid.New()) + if err != nil { + t.Fatal(err) + } + store.now = func() time.Time { return now.Add(2 * time.Minute) } + if _, ok := store.Consume(state); ok { + t.Fatal("expired state must not consume") + } +} + +func TestOAuthStateIssueSweepsExpiredEntries(t *testing.T) { + store := newOAuthStateStore(time.Minute) + now := time.Now().UTC() + store.now = func() time.Time { return now } + stale, err := store.Issue(uuid.New()) + if err != nil { + t.Fatal(err) + } + store.now = func() time.Time { return now.Add(2 * time.Minute) } + if _, err := store.Issue(uuid.New()); err != nil { + t.Fatal(err) + } + store.mu.Lock() + _, exists := store.entries[stale] + size := len(store.entries) + store.mu.Unlock() + if exists || size != 1 { + t.Fatalf("expired entry not swept: exists=%v size=%d", exists, size) + } +} diff --git a/internal/auth/provider_token_cipher.go b/internal/auth/provider_token_cipher.go index f9f5984..d292864 100644 --- a/internal/auth/provider_token_cipher.go +++ b/internal/auth/provider_token_cipher.go @@ -23,7 +23,7 @@ type ProviderTokenCipher struct { func NewProviderTokenCipherFromBase64(value string) (*ProviderTokenCipher, error) { value = strings.TrimSpace(value) if value == "" { - return nil, errors.New("AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64 is required when Apple OAuth is enabled") + return nil, errors.New("AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64 is required when Apple OAuth or a streaming integration is enabled") } key, err := base64.StdEncoding.DecodeString(value) if err != nil { diff --git a/internal/auth/streaming_account.go b/internal/auth/streaming_account.go new file mode 100644 index 0000000..2fcdeff --- /dev/null +++ b/internal/auth/streaming_account.go @@ -0,0 +1,148 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// StreamingProvider는 RTMP 송출 대상 플랫폼 식별자다. 로그인 공급자 +// (OAuthProvider)와는 별개 개념이다 — 로그인은 이메일로 하고 송출은 +// YouTube 채널로 하는 식으로 연결이 독립적이다. +type StreamingProvider string + +const ( + StreamingProviderYouTube StreamingProvider = "youtube" + StreamingProviderChzzk StreamingProvider = "chzzk" +) + +var ErrStreamingAccountNotFound = errors.New("streaming account not found") + +// StreamingAccount는 사용자가 연결한 송출 플랫폼 계정이다. 사용자당 플랫폼별 +// 1개(멀티 채널 불허)로 시작하며, 멀티 채널이 필요해지면 유니크 인덱스를 풀고 +// 선택 채널 포인터를 추가하는 마이그레이션으로 확장한다. +type StreamingAccount struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + + UserID uuid.UUID `gorm:"type:uuid;not null;index;uniqueIndex:uidx_streaming_user_provider,priority:1"` + User *User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + + Provider StreamingProvider `gorm:"type:varchar(20);not null;uniqueIndex:uidx_streaming_user_provider,priority:2;check:chk_streaming_provider,provider IN ('youtube','chzzk')"` + + // 연결 상태 표시("○○ 채널에 연결됨")에 쓰는 플랫폼 쪽 채널 식별 정보. + // 연결(콜백) 시점에 플랫폼 API로 조회해 저장한다. + ChannelID string `gorm:"type:varchar(255);not null"` + ChannelTitle *string `gorm:"type:varchar(255)"` + + // 플랫폼 OAuth refresh token의 AES-GCM 암호문. 평문은 저장하지 않는다. + RefreshTokenCiphertext []byte `gorm:"type:bytea"` + TokenKeyVersion *int16 `gorm:"type:smallint"` + + // refresh token 자체의 만료 시각. Google OAuth는 앱 게시 상태가 Testing이면 + // 토큰 응답에 refresh_token_expires_in(실측 7일)을 담아 보낸다 — 무기한이면 + // NULL이다. 이 값을 추적하지 않으면 연결이 만료 후 조용히 죽는다. + RefreshTokenExpiresAt *time.Time + + // 치지직처럼 ingest URL을 API로 제공하지 않는 플랫폼을 위한 수동 설정값. + // 연결(OAuth) 플로우는 이 컬럼을 건드리지 않는다. + ManualIngestURL *string `gorm:"type:text"` + + ConnectedAt time.Time `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (StreamingAccount) TableName() string { return "streaming_accounts" } + +// StreamingAccountStore는 송출 계정 연결의 영속화 계약이다. +type StreamingAccountStore interface { + // Upsert는 연결을 저장한다. 같은 (user, provider) 재연결이면 채널 정보와 + // 토큰을 교체하고 기존 행(ID)을 유지한다. + Upsert(ctx context.Context, account StreamingAccount) error + Get(ctx context.Context, userID uuid.UUID, provider StreamingProvider) (StreamingAccount, error) + // UpdateRefreshToken은 토큰 갱신 응답이 새 refresh token을 담아온 경우 + // 행 락 하에 교체한다 — 한 사용자의 다중 세션이 동시에 갱신할 때 나중에 + // 실패한 쓰기가 최신 토큰을 덮지 않도록 잠근다. + UpdateRefreshToken(ctx context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error +} + +type gormStreamingAccountStore struct { + db *gorm.DB + now func() time.Time +} + +func NewGormStreamingAccountStore(db *gorm.DB) StreamingAccountStore { + return &gormStreamingAccountStore{db: db, now: func() time.Time { return time.Now().UTC() }} +} + +func (s *gormStreamingAccountStore) Upsert(ctx context.Context, account StreamingAccount) error { + if s == nil || s.db == nil { + return errors.New("streaming account database is nil") + } + now := s.now() + if account.ID == uuid.Nil { + account.ID = uuid.New() + } + account.ConnectedAt = now + account.CreatedAt = now + account.UpdatedAt = now + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := ensureActiveUser(tx, account.UserID); err != nil { + return err + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "provider"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "channel_id", "channel_title", + "refresh_token_ciphertext", "token_key_version", "refresh_token_expires_at", + "connected_at", "updated_at", + }), + }).Create(&account).Error + }) +} + +func (s *gormStreamingAccountStore) Get(ctx context.Context, userID uuid.UUID, provider StreamingProvider) (StreamingAccount, error) { + if s == nil || s.db == nil { + return StreamingAccount{}, errors.New("streaming account database is nil") + } + var account StreamingAccount + result := s.db.WithContext(ctx). + Where("user_id = ? AND provider = ?", userID, provider). + Take(&account) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return StreamingAccount{}, ErrStreamingAccountNotFound + } + if result.Error != nil { + return StreamingAccount{}, result.Error + } + return account, nil +} + +func (s *gormStreamingAccountStore) UpdateRefreshToken(ctx context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error { + if s == nil || s.db == nil { + return errors.New("streaming account database is nil") + } + now := s.now() + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current StreamingAccount + result := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + Take(¤t) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return ErrStreamingAccountNotFound + } + if result.Error != nil { + return result.Error + } + return tx.Model(&StreamingAccount{}).Where("id = ?", id).Updates(map[string]any{ + "refresh_token_ciphertext": ciphertext, + "token_key_version": version, + "refresh_token_expires_at": expiresAt, + "updated_at": now, + }).Error + }) +} diff --git a/internal/auth/streaming_account_postgres_test.go b/internal/auth/streaming_account_postgres_test.go new file mode 100644 index 0000000..bf12edb --- /dev/null +++ b/internal/auth/streaming_account_postgres_test.go @@ -0,0 +1,138 @@ +package auth + +import ( + "context" + "errors" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// TestPostgresStreamingAccountUpsert verifies the PostgreSQL-specific pieces a +// memory double cannot: the ON CONFLICT (user_id, provider) upsert path, the +// unique index, and the row-locked refresh-token update. Set TEST_DATABASE_URL +// to run it; each run uses an isolated, temporary schema. +func TestPostgresStreamingAccountUpsert(t *testing.T) { + databaseURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) + if databaseURL == "" { + t.Skip("set TEST_DATABASE_URL to run PostgreSQL streaming account integration test") + } + + db := newPostgresStreamingTestDB(t, databaseURL) + now := time.Now().UTC().Truncate(time.Microsecond) + user := User{ID: uuid.New(), Status: UserStatusActive, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + store := NewGormStreamingAccountStore(db) + ctx := context.Background() + + title := "Team Framework" + first := StreamingAccount{ + UserID: user.ID, + Provider: StreamingProviderYouTube, + ChannelID: "UCfirst", + ChannelTitle: &title, + RefreshTokenCiphertext: []byte{1, 2, 3}, + } + if err := store.Upsert(ctx, first); err != nil { + t.Fatal(err) + } + created, err := store.Get(ctx, user.ID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + + // 재연결: 같은 (user, provider)는 새 행이 아니라 기존 행 갱신이어야 한다. + second := StreamingAccount{ + UserID: user.ID, + Provider: StreamingProviderYouTube, + ChannelID: "UCsecond", + RefreshTokenCiphertext: []byte{4, 5, 6}, + } + if err := store.Upsert(ctx, second); err != nil { + t.Fatal(err) + } + updated, err := store.Get(ctx, user.ID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + if updated.ID != created.ID { + t.Fatalf("re-connect created a new row: %s -> %s", created.ID, updated.ID) + } + if updated.ChannelID != "UCsecond" || string(updated.RefreshTokenCiphertext) != string([]byte{4, 5, 6}) { + t.Fatalf("re-connect did not replace channel/token: %+v", updated) + } + var count int64 + if err := db.Model(&StreamingAccount{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("rows for user = %d, want 1 (unique index)", count) + } + + // 행 락 기반 refresh token 교체. + expiresAt := now.Add(7 * 24 * time.Hour) + version := int16(1) + if err := store.UpdateRefreshToken(ctx, updated.ID, []byte{7, 8, 9}, &version, &expiresAt); err != nil { + t.Fatal(err) + } + rotated, err := store.Get(ctx, user.ID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + if string(rotated.RefreshTokenCiphertext) != string([]byte{7, 8, 9}) || rotated.RefreshTokenExpiresAt == nil { + t.Fatalf("refresh token not rotated: %+v", rotated) + } + if err := store.UpdateRefreshToken(ctx, uuid.New(), []byte{0}, &version, nil); !errors.Is(err, ErrStreamingAccountNotFound) { + t.Fatalf("unknown id error = %v, want ErrStreamingAccountNotFound", err) + } + + // 비활성 사용자의 연결은 거부돼야 한다. + disabled := User{ID: uuid.New(), Status: UserStatusDisabled, CreatedAt: now, UpdatedAt: now} + if err := db.Create(&disabled).Error; err != nil { + t.Fatal(err) + } + if err := store.Upsert(ctx, StreamingAccount{UserID: disabled.ID, Provider: StreamingProviderYouTube, ChannelID: "UCx"}); !errors.Is(err, ErrUserInactive) { + t.Fatalf("disabled user upsert error = %v, want ErrUserInactive", err) + } +} + +func newPostgresStreamingTestDB(t *testing.T, databaseURL string) *gorm.DB { + t.Helper() + admin, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + schema := "auth_streaming_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { + t.Errorf("drop PostgreSQL test schema: %v", err) + } + }) + + parsed, err := url.Parse(databaseURL) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + query.Set("search_path", schema) + parsed.RawQuery = query.Encode() + db, err := gorm.Open(postgres.Open(parsed.String()), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&User{}, &StreamingAccount{}); err != nil { + t.Fatal(err) + } + return db +} diff --git a/internal/auth/token_http.go b/internal/auth/token_http.go index 90e1028..b96f02e 100644 --- a/internal/auth/token_http.go +++ b/internal/auth/token_http.go @@ -22,6 +22,7 @@ type tokenHTTPHandler struct { apple *AppleLoginService email *EmailAuthService withdrawal *AccountWithdrawalService + youtube *YouTubeConnectService logger *slog.Logger config TokenHTTPConfig } @@ -35,26 +36,32 @@ func MountAuthHTTP(next http.Handler, service *TokenService, google *GoogleLogin if len(appleServices) > 0 { apple = appleServices[0] } - return mountAuthHTTP(next, service, google, apple, nil, nil, logger, config) + return mountAuthHTTP(next, service, google, apple, nil, nil, nil, logger, config) } // MountAuthHTTPWithWithdrawal adds account deletion to the authentication // routes. It is kept separate to preserve the existing constructor used by // smaller deployments and focused handler tests. func MountAuthHTTPWithWithdrawal(next http.Handler, service *TokenService, google *GoogleLoginService, apple *AppleLoginService, withdrawal *AccountWithdrawalService, logger *slog.Logger, config TokenHTTPConfig) http.Handler { - return mountAuthHTTP(next, service, google, apple, nil, withdrawal, logger, config) + return mountAuthHTTP(next, service, google, apple, nil, withdrawal, nil, logger, config) } // MountAuthHTTPWithServices mounts all configured authentication services. -func MountAuthHTTPWithServices(next http.Handler, service *TokenService, google *GoogleLoginService, apple *AppleLoginService, email *EmailAuthService, withdrawal *AccountWithdrawalService, logger *slog.Logger, config TokenHTTPConfig) http.Handler { - return mountAuthHTTP(next, service, google, apple, email, withdrawal, logger, config) +// youtubeServices는 송출 연동(YouTube OAuth)을 함께 마운트할 때만 전달한다 — +// 기존 호출부(테스트 포함)를 깨지 않도록 가변 인자로 확장했다. +func MountAuthHTTPWithServices(next http.Handler, service *TokenService, google *GoogleLoginService, apple *AppleLoginService, email *EmailAuthService, withdrawal *AccountWithdrawalService, logger *slog.Logger, config TokenHTTPConfig, youtubeServices ...*YouTubeConnectService) http.Handler { + var youtube *YouTubeConnectService + if len(youtubeServices) > 0 { + youtube = youtubeServices[0] + } + return mountAuthHTTP(next, service, google, apple, email, withdrawal, youtube, logger, config) } -func mountAuthHTTP(next http.Handler, service *TokenService, google *GoogleLoginService, apple *AppleLoginService, email *EmailAuthService, withdrawal *AccountWithdrawalService, logger *slog.Logger, config TokenHTTPConfig) http.Handler { +func mountAuthHTTP(next http.Handler, service *TokenService, google *GoogleLoginService, apple *AppleLoginService, email *EmailAuthService, withdrawal *AccountWithdrawalService, youtube *YouTubeConnectService, logger *slog.Logger, config TokenHTTPConfig) http.Handler { if logger == nil { logger = slog.Default() } - h := &tokenHTTPHandler{service: service, google: google, apple: apple, email: email, withdrawal: withdrawal, logger: logger, config: config} + h := &tokenHTTPHandler{service: service, google: google, apple: apple, email: email, withdrawal: withdrawal, youtube: youtube, logger: logger, config: config} mux := http.NewServeMux() mux.Handle("/auth/refresh", h.middleware(http.HandlerFunc(h.handleRefresh))) mux.Handle("/auth/logout", h.middleware(http.HandlerFunc(h.handleLogout))) @@ -74,6 +81,11 @@ func mountAuthHTTP(next http.Handler, service *TokenService, google *GoogleLogin if h.withdrawal != nil { mux.Handle("DELETE /auth/me", h.middleware(http.HandlerFunc(h.handleWithdrawal))) } + if h.youtube != nil { + mux.Handle("POST /auth/youtube/connect", h.middleware(http.HandlerFunc(h.handleYouTubeConnect))) + // 콜백은 브라우저 리다이렉트 도착이라 GET이며, state가 사용자 바인딩이다. + mux.Handle("GET /auth/youtube/callback", h.middleware(http.HandlerFunc(h.handleYouTubeCallback))) + } mux.Handle("/", next) return mux } diff --git a/internal/auth/youtube_oauth.go b/internal/auth/youtube_oauth.go new file mode 100644 index 0000000..bc6c434 --- /dev/null +++ b/internal/auth/youtube_oauth.go @@ -0,0 +1,419 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/google/uuid" +) + +const ( + googleOAuthAuthorizeEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" + googleOAuthTokenEndpoint = "https://oauth2.googleapis.com/token" + youtubeChannelsEndpoint = "https://www.googleapis.com/youtube/v3/channels" + // youtubeStreamingScope는 Live API까지 포함하는 최소 스코프다. 이보다 좁은 + // 라이브 전용 스코프는 존재하지 않는다(2026-08-09 조사). + youtubeStreamingScope = "https://www.googleapis.com/auth/youtube" + maxYouTubeOAuthField = 512 + oauthStateTTL = 10 * time.Minute + // accessTokenExpirySlack만큼 만료를 앞당겨 취급해, 발급 직후 만료되는 + // 토큰으로 API를 치는 경계 상황을 피한다. + accessTokenExpirySlack = 60 * time.Second +) + +var ( + ErrYouTubeOAuthDenied = errors.New("YouTube authorization was denied by the user") + ErrInvalidOAuthState = errors.New("OAuth state is invalid or expired") + ErrYouTubeChannelMissing = errors.New("Google account has no YouTube channel") + ErrYouTubeTokenExchange = errors.New("YouTube token exchange failed") + ErrStreamingNotConnected = errors.New("streaming account is not connected") +) + +// YouTubeOAuthConfig는 송출 연동 전용 Google OAuth 클라이언트 설정이다. +// 로그인용(GOOGLE_OAUTH_WEB_CLIENT_ID)과는 별도 GCP 프로젝트·클라이언트다. +type YouTubeOAuthConfig struct { + ClientID string + ClientSecret string + RedirectURI string +} + +func LoadYouTubeOAuthConfigFromEnv() (YouTubeOAuthConfig, error) { + config := YouTubeOAuthConfig{ + ClientID: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_CLIENT_ID")), + ClientSecret: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_CLIENT_SECRET")), + RedirectURI: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_REDIRECT_URI")), + } + values := []string{config.ClientID, config.ClientSecret, config.RedirectURI} + empty := 0 + for _, value := range values { + if value == "" { + empty++ + } + if utf8.RuneCountInString(value) > maxYouTubeOAuthField { + return YouTubeOAuthConfig{}, errors.New("YouTube OAuth configuration value is too long") + } + } + if empty == len(values) { + return YouTubeOAuthConfig{}, nil + } + if empty != 0 { + return YouTubeOAuthConfig{}, errors.New("YOUTUBE_OAUTH_CLIENT_ID, YOUTUBE_OAUTH_CLIENT_SECRET, and YOUTUBE_OAUTH_REDIRECT_URI must be configured together") + } + return config, nil +} + +func (c YouTubeOAuthConfig) Enabled() bool { return c.ClientID != "" } + +// YouTubeTokenResponse는 Google 토큰 엔드포인트 응답이다(2026-08-09 실측 기준). +type YouTubeTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + // RefreshTokenExpiresIn은 앱 게시 상태가 Testing일 때만 온다(실측 604799 + // = 7일). 0이면 refresh token은 무기한이다. + RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +type YouTubeChannel struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// YouTubeAuthorizer는 Google OAuth·YouTube Data API와의 통신 계약이다. +// 서비스 계층 테스트에서 대역으로 대체된다. +type YouTubeAuthorizer interface { + AuthorizeURL(state string) string + Exchange(ctx context.Context, code string) (YouTubeTokenResponse, error) + RefreshAccessToken(ctx context.Context, refreshToken string) (YouTubeTokenResponse, error) + ChannelForToken(ctx context.Context, accessToken string) (YouTubeChannel, error) +} + +type youtubeOAuthClient struct { + config YouTubeOAuthConfig + httpClient *http.Client + authorizeURL string + tokenURL string + channelsURL string +} + +func NewYouTubeOAuthClient(config YouTubeOAuthConfig) (*youtubeOAuthClient, error) { + if !config.Enabled() { + return nil, errors.New("YouTube OAuth is not configured") + } + return &youtubeOAuthClient{ + config: config, + httpClient: &http.Client{Timeout: 10 * time.Second}, + authorizeURL: googleOAuthAuthorizeEndpoint, + tokenURL: googleOAuthTokenEndpoint, + channelsURL: youtubeChannelsEndpoint, + }, nil +} + +// AuthorizeURL은 사용자를 보낼 Google 동의 화면 URL을 만든다. +// access_type=offline만으로는 재승인 시 refresh_token이 응답에서 빠질 수 +// 있어 prompt=consent를 함께 강제한다(2026-08-09 실측으로 수신 확인). +func (c *youtubeOAuthClient) AuthorizeURL(state string) string { + query := url.Values{ + "client_id": {c.config.ClientID}, + "redirect_uri": {c.config.RedirectURI}, + "response_type": {"code"}, + "scope": {youtubeStreamingScope}, + "access_type": {"offline"}, + "prompt": {"consent"}, + "state": {state}, + } + return c.authorizeURL + "?" + query.Encode() +} + +// Exchange는 인가 코드를 토큰으로 교환한다. 코드에 '/' 등 예약 문자가 +// 들어오므로(실측) 반드시 form 인코딩을 거친다. +func (c *youtubeOAuthClient) Exchange(ctx context.Context, code string) (YouTubeTokenResponse, error) { + form := url.Values{ + "client_id": {c.config.ClientID}, + "client_secret": {c.config.ClientSecret}, + "code": {strings.TrimSpace(code)}, + "grant_type": {"authorization_code"}, + "redirect_uri": {c.config.RedirectURI}, + } + return c.requestToken(ctx, form) +} + +// RefreshAccessToken은 refresh token으로 새 access token을 받는다. Google은 +// refresh token을 회전하지 않는 것이 기본이지만, 응답에 새 값이 오면 호출자가 +// 교체 저장해야 한다. +func (c *youtubeOAuthClient) RefreshAccessToken(ctx context.Context, refreshToken string) (YouTubeTokenResponse, error) { + form := url.Values{ + "client_id": {c.config.ClientID}, + "client_secret": {c.config.ClientSecret}, + "refresh_token": {strings.TrimSpace(refreshToken)}, + "grant_type": {"refresh_token"}, + } + return c.requestToken(ctx, form) +} + +func (c *youtubeOAuthClient) requestToken(ctx context.Context, form url.Values) (YouTubeTokenResponse, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return YouTubeTokenResponse{}, err + } + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := c.httpClient.Do(request) + if err != nil { + return YouTubeTokenResponse{}, fmt.Errorf("request Google token endpoint: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8<<10)) + return YouTubeTokenResponse{}, fmt.Errorf("%w: HTTP %d", ErrYouTubeTokenExchange, response.StatusCode) + } + var result YouTubeTokenResponse + if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&result); err != nil { + return YouTubeTokenResponse{}, fmt.Errorf("decode Google token response: %w", err) + } + if strings.TrimSpace(result.AccessToken) == "" { + return YouTubeTokenResponse{}, fmt.Errorf("%w: response has no access_token", ErrYouTubeTokenExchange) + } + return result, nil +} + +// ChannelForToken은 토큰이 매핑되는 YouTube 채널을 식별한다. 채널이 없는 +// Google 계정(실사용 가능 시나리오)은 ErrYouTubeChannelMissing으로 구분한다. +func (c *youtubeOAuthClient) ChannelForToken(ctx context.Context, accessToken string) (YouTubeChannel, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.channelsURL+"?part=snippet&mine=true", nil) + if err != nil { + return YouTubeChannel{}, err + } + request.Header.Set("Authorization", "Bearer "+accessToken) + response, err := c.httpClient.Do(request) + if err != nil { + return YouTubeChannel{}, fmt.Errorf("request YouTube channels: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8<<10)) + return YouTubeChannel{}, fmt.Errorf("YouTube channels.list returned HTTP %d", response.StatusCode) + } + var payload struct { + Items []struct { + ID string `json:"id"` + Snippet struct { + Title string `json:"title"` + } `json:"snippet"` + } `json:"items"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 256<<10)).Decode(&payload); err != nil { + return YouTubeChannel{}, fmt.Errorf("decode YouTube channels response: %w", err) + } + if len(payload.Items) == 0 { + return YouTubeChannel{}, ErrYouTubeChannelMissing + } + item := payload.Items[0] + if strings.TrimSpace(item.ID) == "" { + return YouTubeChannel{}, errors.New("YouTube channels response has no channel id") + } + return YouTubeChannel{ID: item.ID, Title: item.Snippet.Title}, nil +} + +// YouTubeConnectService는 계정 연결 플로우(connect → 동의 → callback)를 +// 오케스트레이션한다. +type YouTubeConnectService struct { + oauth YouTubeAuthorizer + states *oauthStateStore + store StreamingAccountStore + users UserStatusChecker + cipher *ProviderTokenCipher + now func() time.Time +} + +func NewYouTubeConnectService(oauth YouTubeAuthorizer, store StreamingAccountStore, users UserStatusChecker, cipher *ProviderTokenCipher) (*YouTubeConnectService, error) { + if oauth == nil || store == nil || users == nil || cipher == nil { + return nil, errors.New("YouTube connect dependencies must not be nil") + } + return &YouTubeConnectService{ + oauth: oauth, + states: newOAuthStateStore(oauthStateTTL), + store: store, + users: users, + cipher: cipher, + now: func() time.Time { return time.Now().UTC() }, + }, nil +} + +// Connect는 연결 플로우를 시작한다: 사용자에 바인딩된 state를 발급하고 +// 클라이언트가 이동할 authorize URL을 돌려준다. connect 엔드포인트의 인라인 +// Bearer 인증은 사용자 상태를 확인하지 않으므로 여기서 active를 확인한다. +func (s *YouTubeConnectService) Connect(ctx context.Context, userID uuid.UUID) (string, error) { + if err := s.ensureActive(ctx, userID); err != nil { + return "", err + } + state, err := s.states.Issue(userID) + if err != nil { + return "", err + } + return s.oauth.AuthorizeURL(state), nil +} + +// CompleteCallback은 콜백의 state로 사용자를 복원하고, 코드를 토큰으로 교환한 +// 뒤 채널을 식별해 연결을 저장한다. +func (s *YouTubeConnectService) CompleteCallback(ctx context.Context, state, code string) (YouTubeChannel, error) { + userID, ok := s.states.Consume(state) + if !ok { + return YouTubeChannel{}, ErrInvalidOAuthState + } + if err := s.ensureActive(ctx, userID); err != nil { + return YouTubeChannel{}, err + } + token, err := s.oauth.Exchange(ctx, code) + if err != nil { + return YouTubeChannel{}, err + } + // refresh token 없이 연결을 저장하면 access token 만료(1시간) 후 송출이 + // 조용히 죽는다. prompt=consent로 항상 와야 하며, 안 왔다면 연결 실패다. + if strings.TrimSpace(token.RefreshToken) == "" { + return YouTubeChannel{}, fmt.Errorf("%w: response has no refresh_token", ErrYouTubeTokenExchange) + } + channel, err := s.oauth.ChannelForToken(ctx, token.AccessToken) + if err != nil { + return YouTubeChannel{}, err + } + ciphertext, version, err := s.cipher.Encrypt(token.RefreshToken) + if err != nil { + return YouTubeChannel{}, err + } + account := StreamingAccount{ + UserID: userID, + Provider: StreamingProviderYouTube, + ChannelID: channel.ID, + ChannelTitle: googleOptionalString(channel.Title, 255), + RefreshTokenCiphertext: ciphertext, + TokenKeyVersion: version, + RefreshTokenExpiresAt: s.refreshTokenExpiry(token), + } + if err := s.store.Upsert(ctx, account); err != nil { + return YouTubeChannel{}, fmt.Errorf("persist streaming account: %w", err) + } + return channel, nil +} + +func (s *YouTubeConnectService) ensureActive(ctx context.Context, userID uuid.UUID) error { + status, err := s.users.UserStatus(ctx, userID) + if err != nil { + return err + } + if status != UserStatusActive { + return ErrUserInactive + } + return nil +} + +func (s *YouTubeConnectService) refreshTokenExpiry(token YouTubeTokenResponse) *time.Time { + if token.RefreshTokenExpiresIn <= 0 { + return nil + } + expiresAt := s.now().Add(time.Duration(token.RefreshTokenExpiresIn) * time.Second) + return &expiresAt +} + +// YouTubeAccessTokenProvider는 저장된 refresh token으로 access token을 +// 발급·캐시한다. 갱신은 사용자 단위로 직렬화한다 — 한 사용자의 방송 세션 +// 여러 개가 동시에 만료를 만나도 토큰 엔드포인트 호출은 한 번만 나간다. +type YouTubeAccessTokenProvider struct { + oauth YouTubeAuthorizer + store StreamingAccountStore + cipher *ProviderTokenCipher + now func() time.Time + + mu sync.Mutex + users map[uuid.UUID]*userAccessToken +} + +type userAccessToken struct { + mu sync.Mutex + token string + expiresAt time.Time +} + +func NewYouTubeAccessTokenProvider(oauth YouTubeAuthorizer, store StreamingAccountStore, cipher *ProviderTokenCipher) (*YouTubeAccessTokenProvider, error) { + if oauth == nil || store == nil || cipher == nil { + return nil, errors.New("YouTube token provider dependencies must not be nil") + } + return &YouTubeAccessTokenProvider{ + oauth: oauth, + store: store, + cipher: cipher, + now: func() time.Time { return time.Now().UTC() }, + users: make(map[uuid.UUID]*userAccessToken), + }, nil +} + +// AccessToken은 유효한 access token을 돌려준다. 캐시가 만료 여유(60초) 안에 +// 있으면 그대로 쓰고, 아니면 refresh token으로 갱신한다. +func (p *YouTubeAccessTokenProvider) AccessToken(ctx context.Context, userID uuid.UUID) (string, error) { + state := p.userState(userID) + // 사용자 단위 락: 같은 사용자의 동시 호출은 첫 갱신을 기다렸다가 캐시를 + // 재사용한다. 다른 사용자끼리는 서로 막지 않는다. + state.mu.Lock() + defer state.mu.Unlock() + if state.token != "" && p.now().Before(state.expiresAt.Add(-accessTokenExpirySlack)) { + return state.token, nil + } + account, err := p.store.Get(ctx, userID, StreamingProviderYouTube) + if err != nil { + if errors.Is(err, ErrStreamingAccountNotFound) { + return "", ErrStreamingNotConnected + } + return "", err + } + if len(account.RefreshTokenCiphertext) == 0 { + return "", ErrStreamingNotConnected + } + refreshToken, err := p.cipher.Decrypt(account.RefreshTokenCiphertext, account.TokenKeyVersion) + if err != nil { + return "", err + } + response, err := p.oauth.RefreshAccessToken(ctx, refreshToken) + if err != nil { + return "", err + } + // Google이 예외적으로 새 refresh token을 주면 행 락 하에 교체 저장한다. + if next := strings.TrimSpace(response.RefreshToken); next != "" && next != refreshToken { + ciphertext, version, err := p.cipher.Encrypt(next) + if err != nil { + return "", err + } + var expiresAt *time.Time + if response.RefreshTokenExpiresIn > 0 { + at := p.now().Add(time.Duration(response.RefreshTokenExpiresIn) * time.Second) + expiresAt = &at + } + if err := p.store.UpdateRefreshToken(ctx, account.ID, ciphertext, version, expiresAt); err != nil { + return "", fmt.Errorf("persist rotated refresh token: %w", err) + } + } + state.token = response.AccessToken + state.expiresAt = p.now().Add(time.Duration(response.ExpiresIn) * time.Second) + return state.token, nil +} + +func (p *YouTubeAccessTokenProvider) userState(userID uuid.UUID) *userAccessToken { + p.mu.Lock() + defer p.mu.Unlock() + state := p.users[userID] + if state == nil { + state = &userAccessToken{} + p.users[userID] = state + } + return state +} diff --git a/internal/auth/youtube_oauth_http.go b/internal/auth/youtube_oauth_http.go new file mode 100644 index 0000000..d221b96 --- /dev/null +++ b/internal/auth/youtube_oauth_http.go @@ -0,0 +1,86 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + + "github.com/google/uuid" +) + +// handleYouTubeConnect는 로그인된 사용자의 YouTube 연결 플로우를 시작한다. +// 브라우저 리다이렉트가 아니라 XHR(POST)이므로 Bearer 인증이 가능하다 — +// 응답의 authorize_url로 클라이언트가 직접 이동하는 2단계 구조다. +func (h *tokenHTTPHandler) handleYouTubeConnect(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + raw, ok := accessBearerToken(r) + if !ok { + h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + return + } + claims, err := h.service.ValidateAccessToken(raw) + if err != nil { + h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + return + } + userID, err := uuid.Parse(claims.Subject) + if err != nil { + h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + return + } + authorizeURL, err := h.youtube.Connect(r.Context(), userID) + if err != nil { + if errors.Is(err, ErrUserInactive) { + h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + return + } + h.logger.Error("YouTube connect failed", "request_id", tokenRequestID(r), "error", err) + h.writeError(w, r, http.StatusInternalServerError, "internal_error", "An unexpected server error occurred.") + return + } + h.writeJSON(w, http.StatusOK, map[string]string{"authorize_url": authorizeURL}) +} + +// handleYouTubeCallback은 Google 동의 후 브라우저 리다이렉트로 도착한다. +// Authorization 헤더가 없으므로 state가 유일한 사용자 바인딩이다. Google은 +// 문서에 없는 파라미터(iss 등, 2026-08-09 실측)를 붙여 보내므로 명시한 +// 파라미터만 읽고 나머지는 무시한다. +func (h *tokenHTTPHandler) handleYouTubeCallback(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + // 사용자가 동의 화면에서 거부하면 code 없이 error=access_denied로 온다. + if oauthError := strings.TrimSpace(query.Get("error")); oauthError != "" { + h.writeError(w, r, http.StatusBadRequest, "youtube_authorization_denied", "YouTube authorization was denied.") + return + } + state := strings.TrimSpace(query.Get("state")) + code := strings.TrimSpace(query.Get("code")) + if state == "" || code == "" { + h.writeError(w, r, http.StatusBadRequest, "bad_request", "Invalid YouTube callback request.") + return + } + channel, err := h.youtube.CompleteCallback(r.Context(), state, code) + if err != nil { + switch { + case errors.Is(err, ErrInvalidOAuthState): + h.writeError(w, r, http.StatusBadRequest, "invalid_oauth_state", "The connection request is invalid or expired. Start the connection again.") + case errors.Is(err, ErrUserInactive): + h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + case errors.Is(err, ErrYouTubeChannelMissing): + h.writeError(w, r, http.StatusUnprocessableEntity, "youtube_channel_missing", "The Google account has no YouTube channel.") + case errors.Is(err, ErrYouTubeTokenExchange): + h.writeError(w, r, http.StatusBadGateway, "youtube_token_exchange_failed", "YouTube authorization could not be completed.") + default: + h.logger.Error("YouTube callback failed", "request_id", tokenRequestID(r), "error", err) + h.writeError(w, r, http.StatusInternalServerError, "internal_error", "An unexpected server error occurred.") + } + return + } + h.writeJSON(w, http.StatusOK, map[string]any{ + "connected": true, + "provider": StreamingProviderYouTube, + "channel": channel, + }) +} diff --git a/internal/auth/youtube_oauth_http_test.go b/internal/auth/youtube_oauth_http_test.go new file mode 100644 index 0000000..4598f8c --- /dev/null +++ b/internal/auth/youtube_oauth_http_test.go @@ -0,0 +1,147 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" +) + +func testYouTubeHTTPHandler(t *testing.T, service *YouTubeConnectService) (*TokenService, http.Handler) { + t.Helper() + config, err := NewTokenHTTPConfig(false, []string{"http://localhost:3000"}) + if err != nil { + t.Fatal(err) + } + tokens := testTokenService(newMemoryRefreshStore()) + handler := MountAuthHTTPWithServices(http.NotFoundHandler(), tokens, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), config, service) + return tokens, handler +} + +func TestYouTubeConnectHTTPRequiresBearer(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + _, handler := testYouTubeHTTPHandler(t, service) + + request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestYouTubeConnectHTTPReturnsAuthorizeURL(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + tokens, handler := testYouTubeHTTPHandler(t, service) + + userID := uuid.New() + pair, err := tokens.IssuePair(context.Background(), userID, ClientInfo{}) + if err != nil { + t.Fatal(err) + } + + request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", nil) + request.Header.Set("Authorization", "Bearer "+pair.AccessToken) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var payload struct { + AuthorizeURL string `json:"authorize_url"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.AuthorizeURL == "" { + t.Fatal("authorize_url is empty") + } +} + +func TestYouTubeCallbackHTTPFlow(t *testing.T) { + oauth := &stubYouTubeAuthorizer{ + token: YouTubeTokenResponse{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3599}, + channel: YouTubeChannel{ID: "UCabc", Title: "Team Framework"}, + } + store := newMemoryStreamingAccountStore() + service := testYouTubeConnectService(t, oauth, store, UserStatusActive) + _, handler := testYouTubeHTTPHandler(t, service) + + userID := uuid.New() + state, err := service.states.Issue(userID) + if err != nil { + t.Fatal(err) + } + + // Google 콜백에는 문서에 없는 파라미터(iss — 2026-08-09 실측)가 섞여 + // 온다. 파서는 이를 무시하고 성공해야 한다. + request := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state="+state+"&code=auth-code&iss=https://accounts.google.com&scope=x", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if _, err := store.Get(context.Background(), userID, StreamingProviderYouTube); err != nil { + t.Fatalf("connection not persisted: %v", err) + } + + // 소비된 state 재사용은 거부돼야 한다. + replay := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state="+state+"&code=auth-code", nil) + replayResponse := httptest.NewRecorder() + handler.ServeHTTP(replayResponse, replay) + if replayResponse.Code != http.StatusBadRequest { + t.Fatalf("replay status = %d, want 400", replayResponse.Code) + } +} + +func TestYouTubeCallbackHTTPErrors(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + _, handler := testYouTubeHTTPHandler(t, service) + + // 사용자가 동의 화면에서 거부한 경우(error=access_denied). + denied := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?error=access_denied&state=whatever", nil) + deniedResponse := httptest.NewRecorder() + handler.ServeHTTP(deniedResponse, denied) + if deniedResponse.Code != http.StatusBadRequest { + t.Fatalf("denied status = %d, want 400", deniedResponse.Code) + } + + // state/code 누락. + missing := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback", nil) + missingResponse := httptest.NewRecorder() + handler.ServeHTTP(missingResponse, missing) + if missingResponse.Code != http.StatusBadRequest { + t.Fatalf("missing params status = %d, want 400", missingResponse.Code) + } + + // 발급된 적 없는 state. + unknown := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state=unknown&code=code", nil) + unknownResponse := httptest.NewRecorder() + handler.ServeHTTP(unknownResponse, unknown) + if unknownResponse.Code != http.StatusBadRequest { + t.Fatalf("unknown state status = %d, want 400", unknownResponse.Code) + } +} + +// TestYouTubeRoutesAbsentWhenServiceNil: YouTube 서비스가 조립되지 않은 +// 배포에서는 라우트 자체가 등록되지 않아야 한다(기존 mount 계약 보존). +func TestYouTubeRoutesAbsentWhenServiceNil(t *testing.T) { + config, err := NewTokenHTTPConfig(false, nil) + if err != nil { + t.Fatal(err) + } + tokens := testTokenService(newMemoryRefreshStore()) + handler := MountAuthHTTPWithServices(http.NotFoundHandler(), tokens, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), config) + + request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 when service is absent", response.Code) + } +} diff --git a/internal/auth/youtube_oauth_test.go b/internal/auth/youtube_oauth_test.go new file mode 100644 index 0000000..823311f --- /dev/null +++ b/internal/auth/youtube_oauth_test.go @@ -0,0 +1,457 @@ +package auth + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" +) + +func testProviderTokenCipher(t *testing.T) *ProviderTokenCipher { + t.Helper() + cipher, err := NewProviderTokenCipherFromBase64(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))) + if err != nil { + t.Fatal(err) + } + return cipher +} + +func TestLoadYouTubeOAuthConfigFromEnv(t *testing.T) { + t.Run("all empty disables", func(t *testing.T) { + t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "") + t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "") + t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "") + config, err := LoadYouTubeOAuthConfigFromEnv() + if err != nil || config.Enabled() { + t.Fatalf("config = %+v, err = %v, want disabled without error", config, err) + } + }) + t.Run("partial config is an error", func(t *testing.T) { + t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "client-id") + t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "") + t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "") + if _, err := LoadYouTubeOAuthConfigFromEnv(); err == nil { + t.Fatal("partial configuration must fail") + } + }) + t.Run("complete config enables", func(t *testing.T) { + t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "client-id") + t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "client-secret") + t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "http://localhost:8000/auth/youtube/callback") + config, err := LoadYouTubeOAuthConfigFromEnv() + if err != nil || !config.Enabled() { + t.Fatalf("config = %+v, err = %v, want enabled", config, err) + } + }) +} + +func TestYouTubeAuthorizeURLParameters(t *testing.T) { + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURI: "http://localhost:8000/auth/youtube/callback", + }) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(client.AuthorizeURL("state-value")) + if err != nil { + t.Fatal(err) + } + query := parsed.Query() + want := map[string]string{ + "client_id": "client-id", + "redirect_uri": "http://localhost:8000/auth/youtube/callback", + "response_type": "code", + "scope": youtubeStreamingScope, + // prompt=consent 없이는 재승인 시 refresh_token이 빠질 수 있다(실측). + "access_type": "offline", + "prompt": "consent", + "state": "state-value", + } + for key, value := range want { + if got := query.Get(key); got != value { + t.Errorf("authorize URL %s = %q, want %q", key, got, value) + } + } +} + +// TestYouTubeExchangeEncodesReservedCharacters: 실측에서 인가 코드에 '/'가 +// 포함돼 왔다 — form 인코딩이 빠지면 코드가 깨진 채 전송된다. +func TestYouTubeExchangeEncodesReservedCharacters(t *testing.T) { + const rawCode = "4/0AXtestCODEwith/slash" + var received url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + received = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at-value","refresh_token":"rt-value","expires_in":3599,"refresh_token_expires_in":604799,"scope":"s","token_type":"Bearer"}`)) + })) + defer server.Close() + + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret", RedirectURI: "http://localhost/cb"}) + if err != nil { + t.Fatal(err) + } + client.tokenURL = server.URL + response, err := client.Exchange(context.Background(), rawCode) + if err != nil { + t.Fatal(err) + } + if received.Get("code") != rawCode { + t.Fatalf("server received code %q, want %q", received.Get("code"), rawCode) + } + if received.Get("grant_type") != "authorization_code" { + t.Fatalf("grant_type = %q", received.Get("grant_type")) + } + if response.RefreshToken != "rt-value" || response.ExpiresIn != 3599 { + t.Fatalf("response = %+v", response) + } + // Testing 게시 상태에서 오는 필드(실측 604799)를 놓치지 않아야 한다. + if response.RefreshTokenExpiresIn != 604799 { + t.Fatalf("refresh_token_expires_in = %d, want 604799", response.RefreshTokenExpiresIn) + } +} + +func TestYouTubeChannelForToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer at-value" { + t.Errorf("authorization header = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"id":"UCabc","snippet":{"title":"Team Framework"}}]}`)) + })) + defer server.Close() + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[]}`)) + })) + defer empty.Close() + + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret", RedirectURI: "http://localhost/cb"}) + if err != nil { + t.Fatal(err) + } + client.channelsURL = server.URL + channel, err := client.ChannelForToken(context.Background(), "at-value") + if err != nil || channel.ID != "UCabc" || channel.Title != "Team Framework" { + t.Fatalf("channel = %+v, err = %v", channel, err) + } + + // 채널 없는 Google 계정(실사용 시나리오)은 전용 에러로 구분돼야 한다. + client.channelsURL = empty.URL + if _, err := client.ChannelForToken(context.Background(), "at-value"); !errors.Is(err, ErrYouTubeChannelMissing) { + t.Fatalf("empty items error = %v, want ErrYouTubeChannelMissing", err) + } +} + +// ---- 서비스 계층 테스트 대역 ---- + +type stubYouTubeAuthorizer struct { + mu sync.Mutex + exchangeCode string + token YouTubeTokenResponse + exchangeErr error + channel YouTubeChannel + channelErr error + refreshToken YouTubeTokenResponse + refreshErr error + refreshCalls atomic.Int64 + refreshedWith string +} + +func (s *stubYouTubeAuthorizer) AuthorizeURL(state string) string { + return "https://accounts.example/authorize?state=" + state +} + +func (s *stubYouTubeAuthorizer) Exchange(_ context.Context, code string) (YouTubeTokenResponse, error) { + s.mu.Lock() + s.exchangeCode = code + s.mu.Unlock() + return s.token, s.exchangeErr +} + +func (s *stubYouTubeAuthorizer) RefreshAccessToken(_ context.Context, refreshToken string) (YouTubeTokenResponse, error) { + s.refreshCalls.Add(1) + s.mu.Lock() + s.refreshedWith = refreshToken + s.mu.Unlock() + return s.refreshToken, s.refreshErr +} + +func (s *stubYouTubeAuthorizer) ChannelForToken(context.Context, string) (YouTubeChannel, error) { + return s.channel, s.channelErr +} + +type memoryStreamingAccountStore struct { + mu sync.Mutex + accounts map[string]StreamingAccount +} + +func newMemoryStreamingAccountStore() *memoryStreamingAccountStore { + return &memoryStreamingAccountStore{accounts: make(map[string]StreamingAccount)} +} + +func streamingKey(userID uuid.UUID, provider StreamingProvider) string { + return userID.String() + "/" + string(provider) +} + +func (s *memoryStreamingAccountStore) Upsert(_ context.Context, account StreamingAccount) error { + s.mu.Lock() + defer s.mu.Unlock() + key := streamingKey(account.UserID, account.Provider) + if existing, ok := s.accounts[key]; ok { + account.ID = existing.ID + } else if account.ID == uuid.Nil { + account.ID = uuid.New() + } + s.accounts[key] = account + return nil +} + +func (s *memoryStreamingAccountStore) Get(_ context.Context, userID uuid.UUID, provider StreamingProvider) (StreamingAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + account, ok := s.accounts[streamingKey(userID, provider)] + if !ok { + return StreamingAccount{}, ErrStreamingAccountNotFound + } + return account, nil +} + +func (s *memoryStreamingAccountStore) UpdateRefreshToken(_ context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + for key, account := range s.accounts { + if account.ID == id { + account.RefreshTokenCiphertext = ciphertext + account.TokenKeyVersion = version + account.RefreshTokenExpiresAt = expiresAt + s.accounts[key] = account + return nil + } + } + return ErrStreamingAccountNotFound +} + +func testYouTubeConnectService(t *testing.T, oauth YouTubeAuthorizer, store StreamingAccountStore, status UserStatus) *YouTubeConnectService { + t.Helper() + service, err := NewYouTubeConnectService(oauth, store, testUserStatusChecker{status: status}, testProviderTokenCipher(t)) + if err != nil { + t.Fatal(err) + } + return service +} + +func TestYouTubeConnectIssuesBoundState(t *testing.T) { + oauth := &stubYouTubeAuthorizer{} + service := testYouTubeConnectService(t, oauth, newMemoryStreamingAccountStore(), UserStatusActive) + userID := uuid.New() + + authorizeURL, err := service.Connect(context.Background(), userID) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(authorizeURL) + if err != nil { + t.Fatal(err) + } + state := parsed.Query().Get("state") + if state == "" { + t.Fatal("authorize URL has no state") + } + got, ok := service.states.Consume(state) + if !ok || got != userID { + t.Fatalf("state binding = (%v, %v), want (%v, true)", got, ok, userID) + } +} + +func TestYouTubeConnectRejectsInactiveUser(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusDisabled) + if _, err := service.Connect(context.Background(), uuid.New()); !errors.Is(err, ErrUserInactive) { + t.Fatalf("Connect error = %v, want ErrUserInactive", err) + } +} + +func TestYouTubeCompleteCallbackPersistsEncryptedConnection(t *testing.T) { + oauth := &stubYouTubeAuthorizer{ + token: YouTubeTokenResponse{ + AccessToken: "at-value", + RefreshToken: "rt-secret", + ExpiresIn: 3599, + RefreshTokenExpiresIn: 604799, + }, + channel: YouTubeChannel{ID: "UCabc", Title: "Team Framework"}, + } + store := newMemoryStreamingAccountStore() + service := testYouTubeConnectService(t, oauth, store, UserStatusActive) + userID := uuid.New() + + state, err := service.states.Issue(userID) + if err != nil { + t.Fatal(err) + } + channel, err := service.CompleteCallback(context.Background(), state, "auth-code") + if err != nil { + t.Fatal(err) + } + if channel.ID != "UCabc" { + t.Fatalf("channel = %+v", channel) + } + + account, err := store.Get(context.Background(), userID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + if account.ChannelID != "UCabc" || account.ChannelTitle == nil || *account.ChannelTitle != "Team Framework" { + t.Fatalf("account channel = %q/%v", account.ChannelID, account.ChannelTitle) + } + // refresh token은 평문이 아니라 암호문으로 저장돼야 한다. + if bytes.Contains(account.RefreshTokenCiphertext, []byte("rt-secret")) { + t.Fatal("refresh token stored in plaintext") + } + plaintext, err := testProviderTokenCipher(t).Decrypt(account.RefreshTokenCiphertext, account.TokenKeyVersion) + if err != nil || plaintext != "rt-secret" { + t.Fatalf("decrypt = (%q, %v)", plaintext, err) + } + // Testing 게시 상태의 refresh token 만료(실측 7일)가 추적돼야 한다. + if account.RefreshTokenExpiresAt == nil { + t.Fatal("RefreshTokenExpiresAt must be tracked when refresh_token_expires_in is present") + } +} + +func TestYouTubeCompleteCallbackRejectsBadState(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + if _, err := service.CompleteCallback(context.Background(), "unknown-state", "code"); !errors.Is(err, ErrInvalidOAuthState) { + t.Fatalf("error = %v, want ErrInvalidOAuthState", err) + } +} + +func TestYouTubeCompleteCallbackRequiresRefreshToken(t *testing.T) { + oauth := &stubYouTubeAuthorizer{token: YouTubeTokenResponse{AccessToken: "at-only"}} + service := testYouTubeConnectService(t, oauth, newMemoryStreamingAccountStore(), UserStatusActive) + state, err := service.states.Issue(uuid.New()) + if err != nil { + t.Fatal(err) + } + if _, err := service.CompleteCallback(context.Background(), state, "code"); !errors.Is(err, ErrYouTubeTokenExchange) { + t.Fatalf("error = %v, want ErrYouTubeTokenExchange (missing refresh_token)", err) + } +} + +// TestYouTubeAccessTokenProviderSerializesRefresh: 같은 사용자의 동시 요청이 +// 토큰 갱신을 한 번만 트리거하고 모두 같은 토큰을 받아야 한다(다중 방송 +// 세션 시나리오). +func TestYouTubeAccessTokenProviderSerializesRefresh(t *testing.T) { + cipher := testProviderTokenCipher(t) + ciphertext, version, err := cipher.Encrypt("rt-secret") + if err != nil { + t.Fatal(err) + } + store := newMemoryStreamingAccountStore() + userID := uuid.New() + if err := store.Upsert(context.Background(), StreamingAccount{ + UserID: userID, + Provider: StreamingProviderYouTube, + ChannelID: "UCabc", + RefreshTokenCiphertext: ciphertext, + TokenKeyVersion: version, + }); err != nil { + t.Fatal(err) + } + oauth := &stubYouTubeAuthorizer{refreshToken: YouTubeTokenResponse{AccessToken: "fresh-at", ExpiresIn: 3600}} + provider, err := NewYouTubeAccessTokenProvider(oauth, store, cipher) + if err != nil { + t.Fatal(err) + } + + const parallel = 8 + tokens := make([]string, parallel) + var wait sync.WaitGroup + for i := 0; i < parallel; i++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + token, err := provider.AccessToken(context.Background(), userID) + if err != nil { + t.Errorf("AccessToken: %v", err) + return + } + tokens[index] = token + }(i) + } + wait.Wait() + + if calls := oauth.refreshCalls.Load(); calls != 1 { + t.Fatalf("refresh calls = %d, want 1 (per-user serialization)", calls) + } + for _, token := range tokens { + if token != "fresh-at" { + t.Fatalf("tokens = %v, want all %q", tokens, "fresh-at") + } + } + oauth.mu.Lock() + refreshedWith := oauth.refreshedWith + oauth.mu.Unlock() + if refreshedWith != "rt-secret" { + t.Fatalf("refreshed with %q, want decrypted refresh token", refreshedWith) + } +} + +func TestYouTubeAccessTokenProviderNotConnected(t *testing.T) { + provider, err := NewYouTubeAccessTokenProvider(&stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), testProviderTokenCipher(t)) + if err != nil { + t.Fatal(err) + } + if _, err := provider.AccessToken(context.Background(), uuid.New()); !errors.Is(err, ErrStreamingNotConnected) { + t.Fatalf("error = %v, want ErrStreamingNotConnected", err) + } +} + +// TestYouTubeAccessTokenProviderPersistsRotatedToken: Google이 예외적으로 새 +// refresh token을 돌려주면 암호화해 교체 저장해야 한다. +func TestYouTubeAccessTokenProviderPersistsRotatedToken(t *testing.T) { + cipher := testProviderTokenCipher(t) + ciphertext, version, err := cipher.Encrypt("rt-old") + if err != nil { + t.Fatal(err) + } + store := newMemoryStreamingAccountStore() + userID := uuid.New() + if err := store.Upsert(context.Background(), StreamingAccount{ + UserID: userID, + Provider: StreamingProviderYouTube, + ChannelID: "UCabc", + RefreshTokenCiphertext: ciphertext, + TokenKeyVersion: version, + }); err != nil { + t.Fatal(err) + } + oauth := &stubYouTubeAuthorizer{refreshToken: YouTubeTokenResponse{AccessToken: "fresh-at", RefreshToken: "rt-new", ExpiresIn: 3600}} + provider, err := NewYouTubeAccessTokenProvider(oauth, store, cipher) + if err != nil { + t.Fatal(err) + } + if _, err := provider.AccessToken(context.Background(), userID); err != nil { + t.Fatal(err) + } + account, err := store.Get(context.Background(), userID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + plaintext, err := cipher.Decrypt(account.RefreshTokenCiphertext, account.TokenKeyVersion) + if err != nil || plaintext != "rt-new" { + t.Fatalf("stored refresh token = (%q, %v), want rt-new", plaintext, err) + } +} diff --git a/internal/database/migration/sql/000003_create_streaming_accounts.down.sql b/internal/database/migration/sql/000003_create_streaming_accounts.down.sql new file mode 100644 index 0000000..3c0778a --- /dev/null +++ b/internal/database/migration/sql/000003_create_streaming_accounts.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS streaming_accounts; + +COMMIT; diff --git a/internal/database/migration/sql/000003_create_streaming_accounts.up.sql b/internal/database/migration/sql/000003_create_streaming_accounts.up.sql new file mode 100644 index 0000000..fc72d4f --- /dev/null +++ b/internal/database/migration/sql/000003_create_streaming_accounts.up.sql @@ -0,0 +1,33 @@ +BEGIN; + +CREATE TABLE streaming_accounts ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + provider VARCHAR(20) NOT NULL, + channel_id VARCHAR(255) NOT NULL, + channel_title VARCHAR(255), + refresh_token_ciphertext BYTEA, + token_key_version SMALLINT, + refresh_token_expires_at TIMESTAMPTZ, + manual_ingest_url TEXT, + connected_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + + CONSTRAINT fk_streaming_accounts_user + FOREIGN KEY (user_id) + REFERENCES users (id) + ON UPDATE CASCADE + ON DELETE CASCADE, + + CONSTRAINT chk_streaming_provider + CHECK (provider IN ('youtube', 'chzzk')), + + CONSTRAINT uidx_streaming_user_provider + UNIQUE (user_id, provider) +); + +CREATE INDEX idx_streaming_accounts_user_id + ON streaming_accounts (user_id); + +COMMIT; From e7314a1b6908fb32c223fcc71edbdacbd8fd4a36 Mon Sep 17 00:00:00 2001 From: itzjb Date: Mon, 10 Aug 2026 17:29:45 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=86=A1=EC=B6=9C=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20=EC=A0=95=EB=B3=B4=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재사용 가능한 방송 스트림을 한 번 만들어 두고 재사용하려면 스트림 식별자와 ingest 주소를 보관해야 한다. 주소 4종은 공개 상수 성격이라 평문으로 두고, 스트림 이름은 방송 비밀번호에 해당하므로 리프레시 토큰과 동일하게 암호화해 저장한다. GORM 모델과 versioned SQL 양쪽을 함께 갱신했다. --- internal/auth/streaming_account.go | 56 +++++++++++++++++++ .../000004_add_streaming_stream_info.down.sql | 12 ++++ .../000004_add_streaming_stream_info.up.sql | 12 ++++ 3 files changed, 80 insertions(+) create mode 100644 internal/database/migration/sql/000004_add_streaming_stream_info.down.sql create mode 100644 internal/database/migration/sql/000004_add_streaming_stream_info.up.sql diff --git a/internal/auth/streaming_account.go b/internal/auth/streaming_account.go index 2fcdeff..f56a209 100644 --- a/internal/auth/streaming_account.go +++ b/internal/auth/streaming_account.go @@ -51,11 +51,36 @@ type StreamingAccount struct { // 연결(OAuth) 플로우는 이 컬럼을 건드리지 않는다. ManualIngestURL *string `gorm:"type:text"` + // 이하 YouTube 재사용 스트림(isReusable=true) 프리로딩 정보 — 첫 방송 + // 준비 때 1회 생성해 저장하고 이후 방송은 재사용한다(방송당 API 3→2회). + // 필드 구성은 liveStreams.insert 응답의 cdn.ingestionInfo 실물 + // (2026-08-10 실측: rtmp/rtmps × 주/백업 4주소 + streamName)을 따른다. + StreamID *string `gorm:"type:varchar(255)"` + IngestionAddress *string `gorm:"type:text"` + BackupIngestionAddress *string `gorm:"type:text"` + RtmpsIngestionAddress *string `gorm:"type:text"` + RtmpsBackupIngestionAddress *string `gorm:"type:text"` + // streamName은 RTMP URL에 붙는 스트림 키 상당의 비밀값이라 refresh token과 + // 같은 방식(AES-GCM)으로 암호화해서만 저장한다. + StreamNameCiphertext []byte `gorm:"type:bytea"` + StreamNameKeyVersion *int16 `gorm:"type:smallint"` + ConnectedAt time.Time `gorm:"not null"` CreatedAt time.Time `gorm:"not null"` UpdatedAt time.Time `gorm:"not null"` } +// StreamInfo는 프리로딩된 재사용 스트림 정보의 갱신 단위다. +type StreamInfo struct { + StreamID string + IngestionAddress string + BackupIngestionAddress string + RtmpsIngestionAddress string + RtmpsBackupIngestionAddress string + StreamNameCiphertext []byte + StreamNameKeyVersion *int16 +} + func (StreamingAccount) TableName() string { return "streaming_accounts" } // StreamingAccountStore는 송출 계정 연결의 영속화 계약이다. @@ -68,6 +93,8 @@ type StreamingAccountStore interface { // 행 락 하에 교체한다 — 한 사용자의 다중 세션이 동시에 갱신할 때 나중에 // 실패한 쓰기가 최신 토큰을 덮지 않도록 잠근다. UpdateRefreshToken(ctx context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error + // UpdateStreamInfo는 프리로딩된 재사용 스트림 정보를 행 락 하에 저장한다. + UpdateStreamInfo(ctx context.Context, id uuid.UUID, info StreamInfo) error } type gormStreamingAccountStore struct { @@ -122,6 +149,35 @@ func (s *gormStreamingAccountStore) Get(ctx context.Context, userID uuid.UUID, p return account, nil } +func (s *gormStreamingAccountStore) UpdateStreamInfo(ctx context.Context, id uuid.UUID, info StreamInfo) error { + if s == nil || s.db == nil { + return errors.New("streaming account database is nil") + } + now := s.now() + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current StreamingAccount + result := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + Take(¤t) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return ErrStreamingAccountNotFound + } + if result.Error != nil { + return result.Error + } + return tx.Model(&StreamingAccount{}).Where("id = ?", id).Updates(map[string]any{ + "stream_id": info.StreamID, + "ingestion_address": info.IngestionAddress, + "backup_ingestion_address": info.BackupIngestionAddress, + "rtmps_ingestion_address": info.RtmpsIngestionAddress, + "rtmps_backup_ingestion_address": info.RtmpsBackupIngestionAddress, + "stream_name_ciphertext": info.StreamNameCiphertext, + "stream_name_key_version": info.StreamNameKeyVersion, + "updated_at": now, + }).Error + }) +} + func (s *gormStreamingAccountStore) UpdateRefreshToken(ctx context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error { if s == nil || s.db == nil { return errors.New("streaming account database is nil") diff --git a/internal/database/migration/sql/000004_add_streaming_stream_info.down.sql b/internal/database/migration/sql/000004_add_streaming_stream_info.down.sql new file mode 100644 index 0000000..0c848a9 --- /dev/null +++ b/internal/database/migration/sql/000004_add_streaming_stream_info.down.sql @@ -0,0 +1,12 @@ +BEGIN; + +ALTER TABLE streaming_accounts + DROP COLUMN IF EXISTS stream_id, + DROP COLUMN IF EXISTS ingestion_address, + DROP COLUMN IF EXISTS backup_ingestion_address, + DROP COLUMN IF EXISTS rtmps_ingestion_address, + DROP COLUMN IF EXISTS rtmps_backup_ingestion_address, + DROP COLUMN IF EXISTS stream_name_ciphertext, + DROP COLUMN IF EXISTS stream_name_key_version; + +COMMIT; diff --git a/internal/database/migration/sql/000004_add_streaming_stream_info.up.sql b/internal/database/migration/sql/000004_add_streaming_stream_info.up.sql new file mode 100644 index 0000000..a2d00e6 --- /dev/null +++ b/internal/database/migration/sql/000004_add_streaming_stream_info.up.sql @@ -0,0 +1,12 @@ +BEGIN; + +ALTER TABLE streaming_accounts + ADD COLUMN stream_id VARCHAR(255), + ADD COLUMN ingestion_address TEXT, + ADD COLUMN backup_ingestion_address TEXT, + ADD COLUMN rtmps_ingestion_address TEXT, + ADD COLUMN rtmps_backup_ingestion_address TEXT, + ADD COLUMN stream_name_ciphertext BYTEA, + ADD COLUMN stream_name_key_version SMALLINT; + +COMMIT; From 3b060786657756379bbbeb0e8925ba3beaa77a5f Mon Sep 17 00:00:00 2001 From: itzjb Date: Mon, 10 Aug 2026 17:30:01 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20YouTube=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=EC=9D=84=20=EC=9D=B8=EA=B0=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=8B=A0=20=EB=B0=A9=EC=8B=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저 리다이렉트 대신 클라이언트가 구글에서 직접 받은 인가 코드를 서버로 넘기는 방식으로 바꿨다. 기존 로그인이 이미 같은 형태이고, 리다이렉트가 없으면 콜백 경로와 state 관리가 통째로 필요 없어진다. 코드 출처에 따라 교환 파라미터가 다르다. 네이티브 SDK가 준 코드는 redirect_uri 없이 교환되지만 웹 팝업이 준 코드는 postmessage를 요구한다. 실측으로 확인한 사실이라 출처를 요청 본문으로 받아 서버가 매핑한다. 출처를 생략하면 네이티브로 간주하므로 기존 계약과 호환된다. 웹 클라이언트가 팝업을 띄우려면 client_id와 스코프가 필요해 조회 엔드포인트를 두었다. client_id는 공개 식별자라 인증을 요구하지 않으며, 스코프를 서버가 내려주므로 클라이언트에 하드코딩이 남지 않는다. --- .env.example | 8 +- internal/auth/oauth_state.go | 73 ------ internal/auth/oauth_state_test.go | 68 ------ internal/auth/token_http.go | 3 +- internal/auth/youtube_oauth.go | 164 +++++++------- internal/auth/youtube_oauth_http.go | 90 +++++--- internal/auth/youtube_oauth_http_test.go | 221 ++++++++++++++----- internal/auth/youtube_oauth_test.go | 269 ++++++++++++++--------- 8 files changed, 476 insertions(+), 420 deletions(-) delete mode 100644 internal/auth/oauth_state.go delete mode 100644 internal/auth/oauth_state_test.go diff --git a/.env.example b/.env.example index 3bc4405..79eab67 100644 --- a/.env.example +++ b/.env.example @@ -94,11 +94,13 @@ APPLE_PRIVATE_KEY_PATH= AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64= # YouTube 송출 연동 (로그인용 Google OAuth와 별개의 전용 GCP 프로젝트/클라이언트) -# 셋 다 설정해야 활성화되며, 활성 시 AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64도 필요합니다. -# REDIRECT_URI는 GCP 콘솔에 등록된 값과 정확히 일치해야 합니다(예: http://localhost:8000/auth/youtube/callback). +# CLIENT_ID/SECRET 둘 다 설정해야 활성화되며, 활성 시 AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64도 필요합니다. +# 클라이언트(네이티브 GoogleSignIn SDK 또는 웹 GIS 팝업)가 획득한 인가 코드를 +# POST /auth/youtube/connect {server_auth_code, code_source} 로 받습니다. redirect URI 설정은 없습니다 — +# 교환 파라미터는 code_source(native|web_popup)별로 서버가 결정합니다. +# 웹 팝업을 쓰려면 GCP 콘솔의 웹 클라이언트에 "승인된 JavaScript 원본"(예: http://localhost:8000)이 등록돼야 합니다. YOUTUBE_OAUTH_CLIENT_ID= YOUTUBE_OAUTH_CLIENT_SECRET= -YOUTUBE_OAUTH_REDIRECT_URI= # Email/password authentication (SMTP + Redis) # AUTH_EMAIL_SMTP_HOST가 비어 있으면 이메일 회원가입 API는 비활성화됩니다. diff --git a/internal/auth/oauth_state.go b/internal/auth/oauth_state.go deleted file mode 100644 index e0ab765..0000000 --- a/internal/auth/oauth_state.go +++ /dev/null @@ -1,73 +0,0 @@ -package auth - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "sync" - "time" - - "github.com/google/uuid" -) - -// oauthStateStore는 OAuth authorize 왕복의 state를 보관한다. 콜백은 브라우저 -// 리다이렉트로 도착해 Authorization 헤더를 실을 수 없으므로, state가 콜백 -// 요청을 시작 사용자와 잇는 유일한 바인딩이다. 단일 인스턴스 배포 전제의 -// 인메모리 저장이며, 서버 재시작 시 진행 중이던 연결 플로우만 무효화된다 -// (사용자가 연결을 다시 시작하면 복구된다). -type oauthStateStore struct { - mu sync.Mutex - entries map[string]oauthStateEntry - ttl time.Duration - now func() time.Time -} - -type oauthStateEntry struct { - userID uuid.UUID - expiresAt time.Time -} - -func newOAuthStateStore(ttl time.Duration) *oauthStateStore { - return &oauthStateStore{ - entries: make(map[string]oauthStateEntry), - ttl: ttl, - now: func() time.Time { return time.Now().UTC() }, - } -} - -// Issue는 사용자에 바인딩된 1회용 state를 발급한다. -func (s *oauthStateStore) Issue(userID uuid.UUID) (string, error) { - raw := make([]byte, 16) - if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("generate OAuth state: %w", err) - } - state := hex.EncodeToString(raw) - now := s.now() - s.mu.Lock() - defer s.mu.Unlock() - // 발급 시점마다 만료 항목을 청소한다. 진행 중인 연결 플로우 수만큼만 - // 쌓이는 맵이라 순회 비용은 무시할 수 있다. - for key, entry := range s.entries { - if !now.Before(entry.expiresAt) { - delete(s.entries, key) - } - } - s.entries[state] = oauthStateEntry{userID: userID, expiresAt: now.Add(s.ttl)} - return state, nil -} - -// Consume은 state를 검증하고 바인딩된 사용자를 돌려준다. 성공·실패와 무관하게 -// 조회된 state는 즉시 제거된다(1회용 — 재사용은 CSRF 재시도 신호다). -func (s *oauthStateStore) Consume(state string) (uuid.UUID, bool) { - s.mu.Lock() - defer s.mu.Unlock() - entry, ok := s.entries[state] - if !ok { - return uuid.Nil, false - } - delete(s.entries, state) - if !s.now().Before(entry.expiresAt) { - return uuid.Nil, false - } - return entry.userID, true -} diff --git a/internal/auth/oauth_state_test.go b/internal/auth/oauth_state_test.go deleted file mode 100644 index 833e457..0000000 --- a/internal/auth/oauth_state_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package auth - -import ( - "testing" - "time" - - "github.com/google/uuid" -) - -func TestOAuthStateIssueAndConsumeOnce(t *testing.T) { - store := newOAuthStateStore(time.Minute) - userID := uuid.New() - state, err := store.Issue(userID) - if err != nil { - t.Fatal(err) - } - if len(state) != 32 { - t.Fatalf("state length = %d, want 32 hex chars", len(state)) - } - - got, ok := store.Consume(state) - if !ok || got != userID { - t.Fatalf("Consume = (%v, %v), want (%v, true)", got, ok, userID) - } - // 1회용: 같은 state 재사용은 실패해야 한다(CSRF 재시도 신호). - if _, ok := store.Consume(state); ok { - t.Fatal("state must be single-use") - } -} - -func TestOAuthStateUnknownAndExpired(t *testing.T) { - store := newOAuthStateStore(time.Minute) - if _, ok := store.Consume("never-issued"); ok { - t.Fatal("unknown state must not consume") - } - - now := time.Now().UTC() - store.now = func() time.Time { return now } - state, err := store.Issue(uuid.New()) - if err != nil { - t.Fatal(err) - } - store.now = func() time.Time { return now.Add(2 * time.Minute) } - if _, ok := store.Consume(state); ok { - t.Fatal("expired state must not consume") - } -} - -func TestOAuthStateIssueSweepsExpiredEntries(t *testing.T) { - store := newOAuthStateStore(time.Minute) - now := time.Now().UTC() - store.now = func() time.Time { return now } - stale, err := store.Issue(uuid.New()) - if err != nil { - t.Fatal(err) - } - store.now = func() time.Time { return now.Add(2 * time.Minute) } - if _, err := store.Issue(uuid.New()); err != nil { - t.Fatal(err) - } - store.mu.Lock() - _, exists := store.entries[stale] - size := len(store.entries) - store.mu.Unlock() - if exists || size != 1 { - t.Fatalf("expired entry not swept: exists=%v size=%d", exists, size) - } -} diff --git a/internal/auth/token_http.go b/internal/auth/token_http.go index b96f02e..686517d 100644 --- a/internal/auth/token_http.go +++ b/internal/auth/token_http.go @@ -83,8 +83,7 @@ func mountAuthHTTP(next http.Handler, service *TokenService, google *GoogleLogin } if h.youtube != nil { mux.Handle("POST /auth/youtube/connect", h.middleware(http.HandlerFunc(h.handleYouTubeConnect))) - // 콜백은 브라우저 리다이렉트 도착이라 GET이며, state가 사용자 바인딩이다. - mux.Handle("GET /auth/youtube/callback", h.middleware(http.HandlerFunc(h.handleYouTubeCallback))) + mux.Handle("GET /auth/youtube/config", h.middleware(http.HandlerFunc(h.handleYouTubeConfig))) } mux.Handle("/", next) return mux diff --git a/internal/auth/youtube_oauth.go b/internal/auth/youtube_oauth.go index bc6c434..ce06bf3 100644 --- a/internal/auth/youtube_oauth.go +++ b/internal/auth/youtube_oauth.go @@ -18,56 +18,76 @@ import ( ) const ( - googleOAuthAuthorizeEndpoint = "https://accounts.google.com/o/oauth2/v2/auth" - googleOAuthTokenEndpoint = "https://oauth2.googleapis.com/token" - youtubeChannelsEndpoint = "https://www.googleapis.com/youtube/v3/channels" - // youtubeStreamingScope는 Live API까지 포함하는 최소 스코프다. 이보다 좁은 - // 라이브 전용 스코프는 존재하지 않는다(2026-08-09 조사). - youtubeStreamingScope = "https://www.googleapis.com/auth/youtube" + googleOAuthTokenEndpoint = "https://oauth2.googleapis.com/token" + youtubeChannelsEndpoint = "https://www.googleapis.com/youtube/v3/channels" + // YouTubeStreamingScope는 Live API까지 포함하는 최소 스코프다. 이보다 좁은 + // 라이브 전용 스코프는 존재하지 않는다(2026-08-09 조사). 클라이언트 SDK가 + // serverAuthCode를 요청할 때 같은 값을 써야 한다. + YouTubeStreamingScope = "https://www.googleapis.com/auth/youtube" maxYouTubeOAuthField = 512 - oauthStateTTL = 10 * time.Minute // accessTokenExpirySlack만큼 만료를 앞당겨 취급해, 발급 직후 만료되는 // 토큰으로 API를 치는 경계 상황을 피한다. accessTokenExpirySlack = 60 * time.Second ) var ( - ErrYouTubeOAuthDenied = errors.New("YouTube authorization was denied by the user") - ErrInvalidOAuthState = errors.New("OAuth state is invalid or expired") - ErrYouTubeChannelMissing = errors.New("Google account has no YouTube channel") - ErrYouTubeTokenExchange = errors.New("YouTube token exchange failed") - ErrStreamingNotConnected = errors.New("streaming account is not connected") + ErrYouTubeChannelMissing = errors.New("Google account has no YouTube channel") + ErrYouTubeTokenExchange = errors.New("YouTube token exchange failed") + ErrYouTubeAuthCodeRejected = errors.New("YouTube authorization code was rejected") + ErrStreamingNotConnected = errors.New("streaming account is not connected") ) +// CodeSource는 인가 코드를 발급받은 클라이언트 유형이다. 교환 시 요구되는 +// redirect_uri가 유형마다 달라(2026-08-10 실측: 웹 팝업 코드는 생략 시 +// "Missing parameter: redirect_uri"로 거절, redirect_uri=postmessage 필수) +// 클라이언트가 출처를 선언하고 서버가 매핑한다. 출처를 잘못 선언해도 교환이 +// 실패할 뿐 보안 영향은 없다(교환 자격증명은 서버의 웹 클라이언트 것 하나). +type CodeSource string + +const ( + // CodeSourceNative: GoogleSignIn SDK의 serverAuthCode. 교환 시 + // redirect_uri 생략(문서 기준 — iOS 실물 코드로 최종 확인 예정). + CodeSourceNative CodeSource = "native" + // CodeSourceWebPopup: GIS initCodeClient(ux_mode: popup)가 발급한 코드. + // 교환 시 redirect_uri=postmessage 필수(실측 확정). + CodeSourceWebPopup CodeSource = "web_popup" +) + +func (s CodeSource) Valid() bool { + return s == CodeSourceNative || s == CodeSourceWebPopup +} + +// exchangeRedirectURI는 출처별 교환 redirect_uri 값이다. 빈 문자열은 +// 파라미터 생략을 뜻한다. +func (s CodeSource) exchangeRedirectURI() string { + if s == CodeSourceWebPopup { + return "postmessage" + } + return "" +} + // YouTubeOAuthConfig는 송출 연동 전용 Google OAuth 클라이언트 설정이다. // 로그인용(GOOGLE_OAUTH_WEB_CLIENT_ID)과는 별도 GCP 프로젝트·클라이언트다. type YouTubeOAuthConfig struct { ClientID string ClientSecret string - RedirectURI string } func LoadYouTubeOAuthConfigFromEnv() (YouTubeOAuthConfig, error) { config := YouTubeOAuthConfig{ ClientID: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_CLIENT_ID")), ClientSecret: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_CLIENT_SECRET")), - RedirectURI: strings.TrimSpace(os.Getenv("YOUTUBE_OAUTH_REDIRECT_URI")), } - values := []string{config.ClientID, config.ClientSecret, config.RedirectURI} - empty := 0 - for _, value := range values { - if value == "" { - empty++ - } + for _, value := range []string{config.ClientID, config.ClientSecret} { if utf8.RuneCountInString(value) > maxYouTubeOAuthField { return YouTubeOAuthConfig{}, errors.New("YouTube OAuth configuration value is too long") } } - if empty == len(values) { + if config.ClientID == "" && config.ClientSecret == "" { return YouTubeOAuthConfig{}, nil } - if empty != 0 { - return YouTubeOAuthConfig{}, errors.New("YOUTUBE_OAUTH_CLIENT_ID, YOUTUBE_OAUTH_CLIENT_SECRET, and YOUTUBE_OAUTH_REDIRECT_URI must be configured together") + if config.ClientID == "" || config.ClientSecret == "" { + return YouTubeOAuthConfig{}, errors.New("YOUTUBE_OAUTH_CLIENT_ID and YOUTUBE_OAUTH_CLIENT_SECRET must be configured together") } return config, nil } @@ -94,18 +114,20 @@ type YouTubeChannel struct { // YouTubeAuthorizer는 Google OAuth·YouTube Data API와의 통신 계약이다. // 서비스 계층 테스트에서 대역으로 대체된다. type YouTubeAuthorizer interface { - AuthorizeURL(state string) string - Exchange(ctx context.Context, code string) (YouTubeTokenResponse, error) + // Exchange의 redirectURI는 코드 출처별 요구값(CodeSource.exchangeRedirectURI) + // 이며, 빈 문자열이면 파라미터를 생략한다. + Exchange(ctx context.Context, code, redirectURI string) (YouTubeTokenResponse, error) RefreshAccessToken(ctx context.Context, refreshToken string) (YouTubeTokenResponse, error) ChannelForToken(ctx context.Context, accessToken string) (YouTubeChannel, error) + // WebClientID는 웹 클라이언트(GIS)가 쓸 공개 클라이언트 식별자다. + WebClientID() string } type youtubeOAuthClient struct { - config YouTubeOAuthConfig - httpClient *http.Client - authorizeURL string - tokenURL string - channelsURL string + config YouTubeOAuthConfig + httpClient *http.Client + tokenURL string + channelsURL string } func NewYouTubeOAuthClient(config YouTubeOAuthConfig) (*youtubeOAuthClient, error) { @@ -113,43 +135,30 @@ func NewYouTubeOAuthClient(config YouTubeOAuthConfig) (*youtubeOAuthClient, erro return nil, errors.New("YouTube OAuth is not configured") } return &youtubeOAuthClient{ - config: config, - httpClient: &http.Client{Timeout: 10 * time.Second}, - authorizeURL: googleOAuthAuthorizeEndpoint, - tokenURL: googleOAuthTokenEndpoint, - channelsURL: youtubeChannelsEndpoint, + config: config, + httpClient: &http.Client{Timeout: 10 * time.Second}, + tokenURL: googleOAuthTokenEndpoint, + channelsURL: youtubeChannelsEndpoint, }, nil } -// AuthorizeURL은 사용자를 보낼 Google 동의 화면 URL을 만든다. -// access_type=offline만으로는 재승인 시 refresh_token이 응답에서 빠질 수 -// 있어 prompt=consent를 함께 강제한다(2026-08-09 실측으로 수신 확인). -func (c *youtubeOAuthClient) AuthorizeURL(state string) string { - query := url.Values{ - "client_id": {c.config.ClientID}, - "redirect_uri": {c.config.RedirectURI}, - "response_type": {"code"}, - "scope": {youtubeStreamingScope}, - "access_type": {"offline"}, - "prompt": {"consent"}, - "state": {state}, - } - return c.authorizeURL + "?" + query.Encode() -} - -// Exchange는 인가 코드를 토큰으로 교환한다. 코드에 '/' 등 예약 문자가 -// 들어오므로(실측) 반드시 form 인코딩을 거친다. -func (c *youtubeOAuthClient) Exchange(ctx context.Context, code string) (YouTubeTokenResponse, error) { +// Exchange는 클라이언트가 획득한 인가 코드를 토큰으로 교환한다. 코드에 '/' +// 등 예약 문자가 들어오므로(실측) 반드시 form 인코딩을 거친다. +func (c *youtubeOAuthClient) Exchange(ctx context.Context, code, redirectURI string) (YouTubeTokenResponse, error) { form := url.Values{ "client_id": {c.config.ClientID}, "client_secret": {c.config.ClientSecret}, "code": {strings.TrimSpace(code)}, "grant_type": {"authorization_code"}, - "redirect_uri": {c.config.RedirectURI}, + } + if redirectURI != "" { + form.Set("redirect_uri", redirectURI) } return c.requestToken(ctx, form) } +func (c *youtubeOAuthClient) WebClientID() string { return c.config.ClientID } + // RefreshAccessToken은 refresh token으로 새 access token을 받는다. Google은 // refresh token을 회전하지 않는 것이 기본이지만, 응답에 새 값이 오면 호출자가 // 교체 저장해야 한다. @@ -176,6 +185,11 @@ func (c *youtubeOAuthClient) requestToken(ctx context.Context, form url.Values) defer response.Body.Close() if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8<<10)) + // 4xx는 코드 자체의 문제(만료·재사용·위조)라 클라이언트 오류로, + // 그 외는 Google 쪽 장애로 구분한다. + if response.StatusCode >= 400 && response.StatusCode < 500 { + return YouTubeTokenResponse{}, fmt.Errorf("%w: HTTP %d", ErrYouTubeAuthCodeRejected, response.StatusCode) + } return YouTubeTokenResponse{}, fmt.Errorf("%w: HTTP %d", ErrYouTubeTokenExchange, response.StatusCode) } var result YouTubeTokenResponse @@ -226,11 +240,11 @@ func (c *youtubeOAuthClient) ChannelForToken(ctx context.Context, accessToken st return YouTubeChannel{ID: item.ID, Title: item.Snippet.Title}, nil } -// YouTubeConnectService는 계정 연결 플로우(connect → 동의 → callback)를 -// 오케스트레이션한다. +// YouTubeConnectService는 클라이언트(네이티브 SDK 또는 웹 GIS 팝업)가 획득한 +// 인가 코드로 계정 연결을 완결한다. 브라우저 리다이렉트가 없으므로 state가 +// 필요 없고, 사용자 바인딩은 connect 엔드포인트의 Bearer 인증이 담당한다. type YouTubeConnectService struct { oauth YouTubeAuthorizer - states *oauthStateStore store StreamingAccountStore users UserStatusChecker cipher *ProviderTokenCipher @@ -243,7 +257,6 @@ func NewYouTubeConnectService(oauth YouTubeAuthorizer, store StreamingAccountSto } return &YouTubeConnectService{ oauth: oauth, - states: newOAuthStateStore(oauthStateTTL), store: store, users: users, cipher: cipher, @@ -251,31 +264,14 @@ func NewYouTubeConnectService(oauth YouTubeAuthorizer, store StreamingAccountSto }, nil } -// Connect는 연결 플로우를 시작한다: 사용자에 바인딩된 state를 발급하고 -// 클라이언트가 이동할 authorize URL을 돌려준다. connect 엔드포인트의 인라인 -// Bearer 인증은 사용자 상태를 확인하지 않으므로 여기서 active를 확인한다. -func (s *YouTubeConnectService) Connect(ctx context.Context, userID uuid.UUID) (string, error) { - if err := s.ensureActive(ctx, userID); err != nil { - return "", err - } - state, err := s.states.Issue(userID) - if err != nil { - return "", err - } - return s.oauth.AuthorizeURL(state), nil -} - -// CompleteCallback은 콜백의 state로 사용자를 복원하고, 코드를 토큰으로 교환한 -// 뒤 채널을 식별해 연결을 저장한다. -func (s *YouTubeConnectService) CompleteCallback(ctx context.Context, state, code string) (YouTubeChannel, error) { - userID, ok := s.states.Consume(state) - if !ok { - return YouTubeChannel{}, ErrInvalidOAuthState - } +// ConnectWithAuthCode는 인가 코드를 토큰으로 교환하고 채널을 식별해 +// 연결을 저장한다. connect 엔드포인트의 인라인 Bearer 인증은 사용자 상태를 +// 확인하지 않으므로 여기서 active를 확인한다. +func (s *YouTubeConnectService) ConnectWithAuthCode(ctx context.Context, userID uuid.UUID, code string, source CodeSource) (YouTubeChannel, error) { if err := s.ensureActive(ctx, userID); err != nil { return YouTubeChannel{}, err } - token, err := s.oauth.Exchange(ctx, code) + token, err := s.oauth.Exchange(ctx, code, source.exchangeRedirectURI()) if err != nil { return YouTubeChannel{}, err } @@ -307,6 +303,12 @@ func (s *YouTubeConnectService) CompleteCallback(ctx context.Context, state, cod return channel, nil } +// WebClientID는 웹(GIS) 클라이언트가 initCodeClient에 쓸 공개 클라이언트 +// 식별자다 — 비밀이 아니며 GET /auth/youtube/config 로 노출된다. +func (s *YouTubeConnectService) WebClientID() string { + return s.oauth.WebClientID() +} + func (s *YouTubeConnectService) ensureActive(ctx context.Context, userID uuid.UUID) error { status, err := s.users.UserStatus(ctx, userID) if err != nil { diff --git a/internal/auth/youtube_oauth_http.go b/internal/auth/youtube_oauth_http.go index d221b96..e7a7aa3 100644 --- a/internal/auth/youtube_oauth_http.go +++ b/internal/auth/youtube_oauth_http.go @@ -1,16 +1,21 @@ package auth import ( + "encoding/json" "errors" + "io" "net/http" "strings" "github.com/google/uuid" ) -// handleYouTubeConnect는 로그인된 사용자의 YouTube 연결 플로우를 시작한다. -// 브라우저 리다이렉트가 아니라 XHR(POST)이므로 Bearer 인증이 가능하다 — -// 응답의 authorize_url로 클라이언트가 직접 이동하는 2단계 구조다. +const maxYouTubeConnectRequestBody = 16 << 10 + +// handleYouTubeConnect는 네이티브 클라이언트(GoogleSignIn SDK)가 획득한 +// serverAuthCode를 받아 YouTube 계정 연결을 완결한다. 브라우저 리다이렉트 +// 없이 XHR 1회로 끝나므로 콜백·state가 없고, 사용자 바인딩은 Bearer 인증이 +// 담당한다. func (h *tokenHTTPHandler) handleYouTubeConnect(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) @@ -31,49 +36,24 @@ func (h *tokenHTTPHandler) handleYouTubeConnect(w http.ResponseWriter, r *http.R h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") return } - authorizeURL, err := h.youtube.Connect(r.Context(), userID) + code, source, err := decodeYouTubeConnectRequest(w, r) if err != nil { - if errors.Is(err, ErrUserInactive) { - h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") - return - } - h.logger.Error("YouTube connect failed", "request_id", tokenRequestID(r), "error", err) - h.writeError(w, r, http.StatusInternalServerError, "internal_error", "An unexpected server error occurred.") - return - } - h.writeJSON(w, http.StatusOK, map[string]string{"authorize_url": authorizeURL}) -} - -// handleYouTubeCallback은 Google 동의 후 브라우저 리다이렉트로 도착한다. -// Authorization 헤더가 없으므로 state가 유일한 사용자 바인딩이다. Google은 -// 문서에 없는 파라미터(iss 등, 2026-08-09 실측)를 붙여 보내므로 명시한 -// 파라미터만 읽고 나머지는 무시한다. -func (h *tokenHTTPHandler) handleYouTubeCallback(w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - // 사용자가 동의 화면에서 거부하면 code 없이 error=access_denied로 온다. - if oauthError := strings.TrimSpace(query.Get("error")); oauthError != "" { - h.writeError(w, r, http.StatusBadRequest, "youtube_authorization_denied", "YouTube authorization was denied.") - return - } - state := strings.TrimSpace(query.Get("state")) - code := strings.TrimSpace(query.Get("code")) - if state == "" || code == "" { - h.writeError(w, r, http.StatusBadRequest, "bad_request", "Invalid YouTube callback request.") + h.writeError(w, r, http.StatusBadRequest, "bad_request", "Invalid YouTube connect request.") return } - channel, err := h.youtube.CompleteCallback(r.Context(), state, code) + channel, err := h.youtube.ConnectWithAuthCode(r.Context(), userID, code, source) if err != nil { switch { - case errors.Is(err, ErrInvalidOAuthState): - h.writeError(w, r, http.StatusBadRequest, "invalid_oauth_state", "The connection request is invalid or expired. Start the connection again.") case errors.Is(err, ErrUserInactive): h.writeError(w, r, http.StatusUnauthorized, "unauthorized", "Authentication is required.") + case errors.Is(err, ErrYouTubeAuthCodeRejected): + h.writeError(w, r, http.StatusBadRequest, "invalid_auth_code", "The authorization code was rejected. Sign in with Google again.") case errors.Is(err, ErrYouTubeChannelMissing): h.writeError(w, r, http.StatusUnprocessableEntity, "youtube_channel_missing", "The Google account has no YouTube channel.") case errors.Is(err, ErrYouTubeTokenExchange): h.writeError(w, r, http.StatusBadGateway, "youtube_token_exchange_failed", "YouTube authorization could not be completed.") default: - h.logger.Error("YouTube callback failed", "request_id", tokenRequestID(r), "error", err) + h.logger.Error("YouTube connect failed", "request_id", tokenRequestID(r), "error", err) h.writeError(w, r, http.StatusInternalServerError, "internal_error", "An unexpected server error occurred.") } return @@ -84,3 +64,45 @@ func (h *tokenHTTPHandler) handleYouTubeCallback(w http.ResponseWriter, r *http. "channel": channel, }) } + +func decodeYouTubeConnectRequest(w http.ResponseWriter, r *http.Request) (string, CodeSource, error) { + request := struct { + ServerAuthCode string `json:"server_auth_code"` + CodeSource string `json:"code_source"` + }{} + r.Body = http.MaxBytesReader(w, r.Body, maxYouTubeConnectRequestBody) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + return "", "", err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return "", "", errors.New("request body must contain one JSON object") + } + request.ServerAuthCode = strings.TrimSpace(request.ServerAuthCode) + if request.ServerAuthCode == "" { + return "", "", errors.New("server_auth_code is required") + } + // code_source는 선택 필드다 — 생략 시 native(기존 계약 하위호환). + source := CodeSource(strings.TrimSpace(request.CodeSource)) + if source == "" { + source = CodeSourceNative + } + if !source.Valid() { + return "", "", errors.New("code_source must be native or web_popup") + } + return request.ServerAuthCode, source, nil +} + +// handleYouTubeConfig는 웹 클라이언트가 GIS 팝업을 초기화하는 데 필요한 +// 공개 설정을 준다. client_id는 공개 식별자라 인증이 필요 없다. +func (h *tokenHTTPHandler) handleYouTubeConfig(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + h.writeJSON(w, http.StatusOK, map[string]string{ + "web_client_id": h.youtube.WebClientID(), + "scope": YouTubeStreamingScope, + }) +} diff --git a/internal/auth/youtube_oauth_http_test.go b/internal/auth/youtube_oauth_http_test.go index 4598f8c..7557f39 100644 --- a/internal/auth/youtube_oauth_http_test.go +++ b/internal/auth/youtube_oauth_http_test.go @@ -1,6 +1,7 @@ package auth import ( + "bytes" "context" "encoding/json" "io" @@ -23,20 +24,34 @@ func testYouTubeHTTPHandler(t *testing.T, service *YouTubeConnectService) (*Toke return tokens, handler } +func youtubeConnectRequest(t *testing.T, accessToken, body string) *http.Request { + t.Helper() + request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", bytes.NewBufferString(body)) + request.Header.Set("Content-Type", "application/json") + if accessToken != "" { + request.Header.Set("Authorization", "Bearer "+accessToken) + } + return request +} + func TestYouTubeConnectHTTPRequiresBearer(t *testing.T) { service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) _, handler := testYouTubeHTTPHandler(t, service) - request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", nil) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + handler.ServeHTTP(response, youtubeConnectRequest(t, "", `{"server_auth_code":"code"}`)) if response.Code != http.StatusUnauthorized { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } } -func TestYouTubeConnectHTTPReturnsAuthorizeURL(t *testing.T) { - service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) +func TestYouTubeConnectHTTPCompletesConnection(t *testing.T) { + oauth := &stubYouTubeAuthorizer{ + token: YouTubeTokenResponse{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3599}, + channel: YouTubeChannel{ID: "UCabc", Title: "Team Framework"}, + } + store := newMemoryStreamingAccountStore() + service := testYouTubeConnectService(t, oauth, store, UserStatusActive) tokens, handler := testYouTubeHTTPHandler(t, service) userID := uuid.New() @@ -45,86 +60,190 @@ func TestYouTubeConnectHTTPReturnsAuthorizeURL(t *testing.T) { t.Fatal(err) } - request := httptest.NewRequest(http.MethodPost, "/auth/youtube/connect", nil) - request.Header.Set("Authorization", "Bearer "+pair.AccessToken) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, `{"server_auth_code":"4/0AXserver-code"}`)) if response.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } var payload struct { - AuthorizeURL string `json:"authorize_url"` + Connected bool `json:"connected"` + Provider string `json:"provider"` + Channel struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"channel"` } if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { t.Fatal(err) } - if payload.AuthorizeURL == "" { - t.Fatal("authorize_url is empty") + if !payload.Connected || payload.Provider != "youtube" || payload.Channel.ID != "UCabc" { + t.Fatalf("payload = %+v", payload) + } + if _, err := store.Get(context.Background(), userID, StreamingProviderYouTube); err != nil { + t.Fatalf("connection not persisted: %v", err) + } +} + +func TestYouTubeConnectHTTPBadRequests(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + tokens, handler := testYouTubeHTTPHandler(t, service) + pair, err := tokens.IssuePair(context.Background(), uuid.New(), ClientInfo{}) + if err != nil { + t.Fatal(err) + } + + for name, body := range map[string]string{ + "empty body": ``, + "missing code": `{}`, + "blank code": `{"server_auth_code":" "}`, + "unknown field": `{"server_auth_code":"x","extra":true}`, + } { + response := httptest.NewRecorder() + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, body)) + if response.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400 (body: %s)", name, response.Code, response.Body.String()) + } + } +} + +func TestYouTubeConnectHTTPMapsExchangeErrors(t *testing.T) { + cases := []struct { + name string + stub *stubYouTubeAuthorizer + wantStatus int + wantCode string + }{ + { + name: "rejected code", + stub: &stubYouTubeAuthorizer{exchangeErr: ErrYouTubeAuthCodeRejected}, + wantStatus: http.StatusBadRequest, + wantCode: "invalid_auth_code", + }, + { + name: "google outage", + stub: &stubYouTubeAuthorizer{exchangeErr: ErrYouTubeTokenExchange}, + wantStatus: http.StatusBadGateway, + wantCode: "youtube_token_exchange_failed", + }, + { + name: "channel missing", + stub: &stubYouTubeAuthorizer{ + token: YouTubeTokenResponse{AccessToken: "at", RefreshToken: "rt"}, + channelErr: ErrYouTubeChannelMissing, + }, + wantStatus: http.StatusUnprocessableEntity, + wantCode: "youtube_channel_missing", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + service := testYouTubeConnectService(t, tc.stub, newMemoryStreamingAccountStore(), UserStatusActive) + tokens, handler := testYouTubeHTTPHandler(t, service) + pair, err := tokens.IssuePair(context.Background(), uuid.New(), ClientInfo{}) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, `{"server_auth_code":"code"}`)) + if response.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d (body: %s)", response.Code, tc.wantStatus, response.Body.String()) + } + var payload struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if payload.Error.Code != tc.wantCode { + t.Fatalf("error code = %q, want %q", payload.Error.Code, tc.wantCode) + } + }) } } -func TestYouTubeCallbackHTTPFlow(t *testing.T) { +// TestYouTubeConnectHTTPCodeSource: code_source가 교환 redirect_uri 매핑으로 +// 이어지고(web_popup→postmessage 실측 계약), 미지 값은 400이어야 한다. +func TestYouTubeConnectHTTPCodeSource(t *testing.T) { oauth := &stubYouTubeAuthorizer{ token: YouTubeTokenResponse{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3599}, - channel: YouTubeChannel{ID: "UCabc", Title: "Team Framework"}, + channel: YouTubeChannel{ID: "UCabc"}, } - store := newMemoryStreamingAccountStore() - service := testYouTubeConnectService(t, oauth, store, UserStatusActive) - _, handler := testYouTubeHTTPHandler(t, service) - - userID := uuid.New() - state, err := service.states.Issue(userID) + service := testYouTubeConnectService(t, oauth, newMemoryStreamingAccountStore(), UserStatusActive) + tokens, handler := testYouTubeHTTPHandler(t, service) + pair, err := tokens.IssuePair(context.Background(), uuid.New(), ClientInfo{}) if err != nil { t.Fatal(err) } - // Google 콜백에는 문서에 없는 파라미터(iss — 2026-08-09 실측)가 섞여 - // 온다. 파서는 이를 무시하고 성공해야 한다. - request := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state="+state+"&code=auth-code&iss=https://accounts.google.com&scope=x", nil) response := httptest.NewRecorder() - handler.ServeHTTP(response, request) + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, `{"server_auth_code":"c","code_source":"web_popup"}`)) if response.Code != http.StatusOK { - t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + t.Fatalf("web_popup status = %d, body = %s", response.Code, response.Body.String()) } - if _, err := store.Get(context.Background(), userID, StreamingProviderYouTube); err != nil { - t.Fatalf("connection not persisted: %v", err) + oauth.mu.Lock() + redirect := oauth.exchangeRedirect + oauth.mu.Unlock() + if redirect != "postmessage" { + t.Fatalf("web_popup exchanged with redirect %q, want postmessage", redirect) + } + + // 생략 시 native(하위호환) — redirect_uri 생략. + response = httptest.NewRecorder() + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, `{"server_auth_code":"c2"}`)) + if response.Code != http.StatusOK { + t.Fatalf("default source status = %d, body = %s", response.Code, response.Body.String()) + } + oauth.mu.Lock() + redirect = oauth.exchangeRedirect + oauth.mu.Unlock() + if redirect != "" { + t.Fatalf("native exchanged with redirect %q, want omitted", redirect) } - // 소비된 state 재사용은 거부돼야 한다. - replay := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state="+state+"&code=auth-code", nil) - replayResponse := httptest.NewRecorder() - handler.ServeHTTP(replayResponse, replay) - if replayResponse.Code != http.StatusBadRequest { - t.Fatalf("replay status = %d, want 400", replayResponse.Code) + // 미지 출처는 400. + response = httptest.NewRecorder() + handler.ServeHTTP(response, youtubeConnectRequest(t, pair.AccessToken, `{"server_auth_code":"c3","code_source":"browser"}`)) + if response.Code != http.StatusBadRequest { + t.Fatalf("unknown source status = %d, want 400", response.Code) } } -func TestYouTubeCallbackHTTPErrors(t *testing.T) { +// TestYouTubeConfigHTTPExposesWebClientID: 웹 클라이언트가 GIS 초기화에 쓸 +// 공개 설정 — 인증 없이 접근 가능해야 한다. +func TestYouTubeConfigHTTPExposesWebClientID(t *testing.T) { service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) _, handler := testYouTubeHTTPHandler(t, service) - // 사용자가 동의 화면에서 거부한 경우(error=access_denied). - denied := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?error=access_denied&state=whatever", nil) - deniedResponse := httptest.NewRecorder() - handler.ServeHTTP(deniedResponse, denied) - if deniedResponse.Code != http.StatusBadRequest { - t.Fatalf("denied status = %d, want 400", deniedResponse.Code) + request := httptest.NewRequest(http.MethodGet, "/auth/youtube/config", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } - - // state/code 누락. - missing := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback", nil) - missingResponse := httptest.NewRecorder() - handler.ServeHTTP(missingResponse, missing) - if missingResponse.Code != http.StatusBadRequest { - t.Fatalf("missing params status = %d, want 400", missingResponse.Code) + var payload struct { + WebClientID string `json:"web_client_id"` + Scope string `json:"scope"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) } + if payload.WebClientID != "stub-web-client-id" || payload.Scope != YouTubeStreamingScope { + t.Fatalf("payload = %+v", payload) + } +} - // 발급된 적 없는 state. - unknown := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state=unknown&code=code", nil) - unknownResponse := httptest.NewRecorder() - handler.ServeHTTP(unknownResponse, unknown) - if unknownResponse.Code != http.StatusBadRequest { - t.Fatalf("unknown state status = %d, want 400", unknownResponse.Code) +// TestYouTubeCallbackRouteRemoved: serverAuthCode 전환으로 브라우저 콜백 +// 라우트는 존재하지 않아야 한다(다음 핸들러로 흘러 404). +func TestYouTubeCallbackRouteRemoved(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) + _, handler := testYouTubeHTTPHandler(t, service) + request := httptest.NewRequest(http.MethodGet, "/auth/youtube/callback?state=x&code=y", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (callback route must not exist)", response.Code) } } diff --git a/internal/auth/youtube_oauth_test.go b/internal/auth/youtube_oauth_test.go index 823311f..a065ed2 100644 --- a/internal/auth/youtube_oauth_test.go +++ b/internal/auth/youtube_oauth_test.go @@ -29,24 +29,21 @@ func TestLoadYouTubeOAuthConfigFromEnv(t *testing.T) { t.Run("all empty disables", func(t *testing.T) { t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "") t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "") - t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "") config, err := LoadYouTubeOAuthConfigFromEnv() if err != nil || config.Enabled() { t.Fatalf("config = %+v, err = %v, want disabled without error", config, err) } }) - t.Run("partial config is an error", func(t *testing.T) { + t.Run("partial credentials is an error", func(t *testing.T) { t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "client-id") t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "") - t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "") if _, err := LoadYouTubeOAuthConfigFromEnv(); err == nil { - t.Fatal("partial configuration must fail") + t.Fatal("partial credentials must fail") } }) - t.Run("complete config enables", func(t *testing.T) { + t.Run("complete credentials enables", func(t *testing.T) { t.Setenv("YOUTUBE_OAUTH_CLIENT_ID", "client-id") t.Setenv("YOUTUBE_OAUTH_CLIENT_SECRET", "client-secret") - t.Setenv("YOUTUBE_OAUTH_REDIRECT_URI", "http://localhost:8000/auth/youtube/callback") config, err := LoadYouTubeOAuthConfigFromEnv() if err != nil || !config.Enabled() { t.Fatalf("config = %+v, err = %v, want enabled", config, err) @@ -54,40 +51,27 @@ func TestLoadYouTubeOAuthConfigFromEnv(t *testing.T) { }) } -func TestYouTubeAuthorizeURLParameters(t *testing.T) { - client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ - ClientID: "client-id", - ClientSecret: "client-secret", - RedirectURI: "http://localhost:8000/auth/youtube/callback", - }) - if err != nil { - t.Fatal(err) +// TestCodeSourceExchangeRedirectURI: 출처별 교환 redirect_uri 매핑 — +// 2026-08-10 실측(웹 팝업 코드는 생략 시 "Missing parameter: redirect_uri", +// postmessage로 성공)을 계약으로 고정한다. +func TestCodeSourceExchangeRedirectURI(t *testing.T) { + if got := CodeSourceNative.exchangeRedirectURI(); got != "" { + t.Fatalf("native redirect = %q, want omitted", got) } - parsed, err := url.Parse(client.AuthorizeURL("state-value")) - if err != nil { - t.Fatal(err) + if got := CodeSourceWebPopup.exchangeRedirectURI(); got != "postmessage" { + t.Fatalf("web_popup redirect = %q, want postmessage", got) } - query := parsed.Query() - want := map[string]string{ - "client_id": "client-id", - "redirect_uri": "http://localhost:8000/auth/youtube/callback", - "response_type": "code", - "scope": youtubeStreamingScope, - // prompt=consent 없이는 재승인 시 refresh_token이 빠질 수 있다(실측). - "access_type": "offline", - "prompt": "consent", - "state": "state-value", - } - for key, value := range want { - if got := query.Get(key); got != value { - t.Errorf("authorize URL %s = %q, want %q", key, got, value) - } + if CodeSource("browser").Valid() { + t.Fatal("unknown code source must be invalid") + } + if !CodeSourceNative.Valid() || !CodeSourceWebPopup.Valid() { + t.Fatal("known code sources must be valid") } } -// TestYouTubeExchangeEncodesReservedCharacters: 실측에서 인가 코드에 '/'가 -// 포함돼 왔다 — form 인코딩이 빠지면 코드가 깨진 채 전송된다. -func TestYouTubeExchangeEncodesReservedCharacters(t *testing.T) { +// TestYouTubeExchangeRedirectURIParameter: redirectURI 인자가 빈 값이면 폼에서 +// 생략되고, 값이 있으면 포함되는지 검증한다. +func TestYouTubeExchangeRedirectURIParameter(t *testing.T) { const rawCode = "4/0AXtestCODEwith/slash" var received url.Values server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -96,29 +80,89 @@ func TestYouTubeExchangeEncodesReservedCharacters(t *testing.T) { } received = r.PostForm w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"access_token":"at-value","refresh_token":"rt-value","expires_in":3599,"refresh_token_expires_in":604799,"scope":"s","token_type":"Bearer"}`)) + _, _ = w.Write([]byte(`{"access_token":"at-value","refresh_token":"rt-value","expires_in":3599,"scope":"s","token_type":"Bearer"}`)) })) defer server.Close() - client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret", RedirectURI: "http://localhost/cb"}) + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret"}) if err != nil { t.Fatal(err) } client.tokenURL = server.URL - response, err := client.Exchange(context.Background(), rawCode) + + t.Run("omitted for native", func(t *testing.T) { + response, err := client.Exchange(context.Background(), rawCode, CodeSourceNative.exchangeRedirectURI()) + if err != nil { + t.Fatal(err) + } + // 실측에서 코드에 '/'가 포함돼 왔다 — form 인코딩 왕복 검증. + if received.Get("code") != rawCode { + t.Fatalf("server received code %q, want %q", received.Get("code"), rawCode) + } + if _, present := received["redirect_uri"]; present { + t.Fatal("redirect_uri must be omitted for native codes") + } + if response.AccessToken != "at-value" || response.RefreshToken != "rt-value" { + t.Fatalf("response = %+v", response) + } + }) + t.Run("postmessage for web popup", func(t *testing.T) { + if _, err := client.Exchange(context.Background(), rawCode, CodeSourceWebPopup.exchangeRedirectURI()); err != nil { + t.Fatal(err) + } + if received.Get("redirect_uri") != "postmessage" { + t.Fatalf("redirect_uri = %q, want postmessage", received.Get("redirect_uri")) + } + }) + if client.WebClientID() != "id" { + t.Fatalf("WebClientID = %q", client.WebClientID()) + } +} + +// TestYouTubeExchangeRejectedCode: Google이 4xx로 거절한 코드는 클라이언트 +// 오류(ErrYouTubeAuthCodeRejected)로, 5xx는 게이트웨이 오류로 구분돼야 한다. +func TestYouTubeExchangeRejectedCode(t *testing.T) { + rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) + })) + defer rejected.Close() + broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer broken.Close() + + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret"}) if err != nil { t.Fatal(err) } - if received.Get("code") != rawCode { - t.Fatalf("server received code %q, want %q", received.Get("code"), rawCode) + client.tokenURL = rejected.URL + if _, err := client.Exchange(context.Background(), "stale-code", ""); !errors.Is(err, ErrYouTubeAuthCodeRejected) { + t.Fatalf("4xx error = %v, want ErrYouTubeAuthCodeRejected", err) } - if received.Get("grant_type") != "authorization_code" { - t.Fatalf("grant_type = %q", received.Get("grant_type")) + client.tokenURL = broken.URL + if _, err := client.Exchange(context.Background(), "any-code", ""); !errors.Is(err, ErrYouTubeTokenExchange) { + t.Fatalf("5xx error = %v, want ErrYouTubeTokenExchange", err) } - if response.RefreshToken != "rt-value" || response.ExpiresIn != 3599 { - t.Fatalf("response = %+v", response) +} + +func TestYouTubeTokenResponseParsesTestingExpiry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","expires_in":3599,"refresh_token_expires_in":604799,"scope":"s","token_type":"Bearer"}`)) + })) + defer server.Close() + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret"}) + if err != nil { + t.Fatal(err) + } + client.tokenURL = server.URL + response, err := client.Exchange(context.Background(), "code", "") + if err != nil { + t.Fatal(err) } - // Testing 게시 상태에서 오는 필드(실측 604799)를 놓치지 않아야 한다. + // Testing 게시 상태에서만 오는 필드(실측 604799=7일). Production 발급분은 + // 필드 자체가 없다(실측 2026-08-10) — 0으로 파싱되어 "무기한"으로 다룬다. if response.RefreshTokenExpiresIn != 604799 { t.Fatalf("refresh_token_expires_in = %d, want 604799", response.RefreshTokenExpiresIn) } @@ -139,7 +183,7 @@ func TestYouTubeChannelForToken(t *testing.T) { })) defer empty.Close() - client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret", RedirectURI: "http://localhost/cb"}) + client, err := NewYouTubeOAuthClient(YouTubeOAuthConfig{ClientID: "id", ClientSecret: "secret"}) if err != nil { t.Fatal(err) } @@ -159,29 +203,29 @@ func TestYouTubeChannelForToken(t *testing.T) { // ---- 서비스 계층 테스트 대역 ---- type stubYouTubeAuthorizer struct { - mu sync.Mutex - exchangeCode string - token YouTubeTokenResponse - exchangeErr error - channel YouTubeChannel - channelErr error - refreshToken YouTubeTokenResponse - refreshErr error - refreshCalls atomic.Int64 - refreshedWith string + mu sync.Mutex + exchangeCode string + exchangeRedirect string + token YouTubeTokenResponse + exchangeErr error + channel YouTubeChannel + channelErr error + refreshToken YouTubeTokenResponse + refreshErr error + refreshCalls atomic.Int64 + refreshedWith string } -func (s *stubYouTubeAuthorizer) AuthorizeURL(state string) string { - return "https://accounts.example/authorize?state=" + state -} - -func (s *stubYouTubeAuthorizer) Exchange(_ context.Context, code string) (YouTubeTokenResponse, error) { +func (s *stubYouTubeAuthorizer) Exchange(_ context.Context, code, redirectURI string) (YouTubeTokenResponse, error) { s.mu.Lock() s.exchangeCode = code + s.exchangeRedirect = redirectURI s.mu.Unlock() return s.token, s.exchangeErr } +func (s *stubYouTubeAuthorizer) WebClientID() string { return "stub-web-client-id" } + func (s *stubYouTubeAuthorizer) RefreshAccessToken(_ context.Context, refreshToken string) (YouTubeTokenResponse, error) { s.refreshCalls.Add(1) s.mu.Lock() @@ -245,6 +289,25 @@ func (s *memoryStreamingAccountStore) UpdateRefreshToken(_ context.Context, id u return ErrStreamingAccountNotFound } +func (s *memoryStreamingAccountStore) UpdateStreamInfo(_ context.Context, id uuid.UUID, info StreamInfo) error { + s.mu.Lock() + defer s.mu.Unlock() + for key, account := range s.accounts { + if account.ID == id { + account.StreamID = &info.StreamID + account.IngestionAddress = &info.IngestionAddress + account.BackupIngestionAddress = &info.BackupIngestionAddress + account.RtmpsIngestionAddress = &info.RtmpsIngestionAddress + account.RtmpsBackupIngestionAddress = &info.RtmpsBackupIngestionAddress + account.StreamNameCiphertext = info.StreamNameCiphertext + account.StreamNameKeyVersion = info.StreamNameKeyVersion + s.accounts[key] = account + return nil + } + } + return ErrStreamingAccountNotFound +} + func testYouTubeConnectService(t *testing.T, oauth YouTubeAuthorizer, store StreamingAccountStore, status UserStatus) *YouTubeConnectService { t.Helper() service, err := NewYouTubeConnectService(oauth, store, testUserStatusChecker{status: status}, testProviderTokenCipher(t)) @@ -254,37 +317,7 @@ func testYouTubeConnectService(t *testing.T, oauth YouTubeAuthorizer, store Stre return service } -func TestYouTubeConnectIssuesBoundState(t *testing.T) { - oauth := &stubYouTubeAuthorizer{} - service := testYouTubeConnectService(t, oauth, newMemoryStreamingAccountStore(), UserStatusActive) - userID := uuid.New() - - authorizeURL, err := service.Connect(context.Background(), userID) - if err != nil { - t.Fatal(err) - } - parsed, err := url.Parse(authorizeURL) - if err != nil { - t.Fatal(err) - } - state := parsed.Query().Get("state") - if state == "" { - t.Fatal("authorize URL has no state") - } - got, ok := service.states.Consume(state) - if !ok || got != userID { - t.Fatalf("state binding = (%v, %v), want (%v, true)", got, ok, userID) - } -} - -func TestYouTubeConnectRejectsInactiveUser(t *testing.T) { - service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusDisabled) - if _, err := service.Connect(context.Background(), uuid.New()); !errors.Is(err, ErrUserInactive) { - t.Fatalf("Connect error = %v, want ErrUserInactive", err) - } -} - -func TestYouTubeCompleteCallbackPersistsEncryptedConnection(t *testing.T) { +func TestYouTubeConnectWithAuthCodePersistsEncryptedConnection(t *testing.T) { oauth := &stubYouTubeAuthorizer{ token: YouTubeTokenResponse{ AccessToken: "at-value", @@ -298,17 +331,19 @@ func TestYouTubeCompleteCallbackPersistsEncryptedConnection(t *testing.T) { service := testYouTubeConnectService(t, oauth, store, UserStatusActive) userID := uuid.New() - state, err := service.states.Issue(userID) - if err != nil { - t.Fatal(err) - } - channel, err := service.CompleteCallback(context.Background(), state, "auth-code") + channel, err := service.ConnectWithAuthCode(context.Background(), userID, "server-auth-code", CodeSourceNative) if err != nil { t.Fatal(err) } if channel.ID != "UCabc" { t.Fatalf("channel = %+v", channel) } + oauth.mu.Lock() + exchanged := oauth.exchangeCode + oauth.mu.Unlock() + if exchanged != "server-auth-code" { + t.Fatalf("exchanged code = %q", exchanged) + } account, err := store.Get(context.Background(), userID, StreamingProviderYouTube) if err != nil { @@ -325,27 +360,45 @@ func TestYouTubeCompleteCallbackPersistsEncryptedConnection(t *testing.T) { if err != nil || plaintext != "rt-secret" { t.Fatalf("decrypt = (%q, %v)", plaintext, err) } - // Testing 게시 상태의 refresh token 만료(실측 7일)가 추적돼야 한다. + // Testing 게시 상태 토큰의 만료(7일)는 재연결 유도 신호로 추적돼야 한다. if account.RefreshTokenExpiresAt == nil { t.Fatal("RefreshTokenExpiresAt must be tracked when refresh_token_expires_in is present") } } -func TestYouTubeCompleteCallbackRejectsBadState(t *testing.T) { - service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusActive) - if _, err := service.CompleteCallback(context.Background(), "unknown-state", "code"); !errors.Is(err, ErrInvalidOAuthState) { - t.Fatalf("error = %v, want ErrInvalidOAuthState", err) +func TestYouTubeConnectWithAuthCodeProductionTokenHasNoExpiry(t *testing.T) { + // Production 게시 발급분은 refresh_token_expires_in 부재(실측 2026-08-10) + // — 만료 추적 컬럼은 NULL이어야 한다. + oauth := &stubYouTubeAuthorizer{ + token: YouTubeTokenResponse{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3599}, + channel: YouTubeChannel{ID: "UCabc"}, + } + store := newMemoryStreamingAccountStore() + service := testYouTubeConnectService(t, oauth, store, UserStatusActive) + userID := uuid.New() + if _, err := service.ConnectWithAuthCode(context.Background(), userID, "code", CodeSourceNative); err != nil { + t.Fatal(err) + } + account, err := store.Get(context.Background(), userID, StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + if account.RefreshTokenExpiresAt != nil { + t.Fatalf("RefreshTokenExpiresAt = %v, want nil for production tokens", account.RefreshTokenExpiresAt) + } +} + +func TestYouTubeConnectWithAuthCodeRejectsInactiveUser(t *testing.T) { + service := testYouTubeConnectService(t, &stubYouTubeAuthorizer{}, newMemoryStreamingAccountStore(), UserStatusDisabled) + if _, err := service.ConnectWithAuthCode(context.Background(), uuid.New(), "code", CodeSourceNative); !errors.Is(err, ErrUserInactive) { + t.Fatalf("error = %v, want ErrUserInactive", err) } } -func TestYouTubeCompleteCallbackRequiresRefreshToken(t *testing.T) { +func TestYouTubeConnectWithAuthCodeRequiresRefreshToken(t *testing.T) { oauth := &stubYouTubeAuthorizer{token: YouTubeTokenResponse{AccessToken: "at-only"}} service := testYouTubeConnectService(t, oauth, newMemoryStreamingAccountStore(), UserStatusActive) - state, err := service.states.Issue(uuid.New()) - if err != nil { - t.Fatal(err) - } - if _, err := service.CompleteCallback(context.Background(), state, "code"); !errors.Is(err, ErrYouTubeTokenExchange) { + if _, err := service.ConnectWithAuthCode(context.Background(), uuid.New(), "code", CodeSourceNative); !errors.Is(err, ErrYouTubeTokenExchange) { t.Fatalf("error = %v, want ErrYouTubeTokenExchange (missing refresh_token)", err) } } From 6a2c022245dc26a6a277717004c801ebada24d28 Mon Sep 17 00:00:00 2001 From: itzjb Date: Mon, 10 Aug 2026 17:30:20 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=B0=A9=EC=86=A1=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EC=82=AC=EC=9D=B4=ED=81=B4=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EB=B0=94=EC=9D=B4=EB=8D=94=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플랫폼마다 송출 준비 절차가 다르다. YouTube는 방송과 스트림을 만들고 둘을 연결해야 하지만 치지직은 스트림 키 조회만으로 끝난다. 키 조회 수준으로 추상화하면 두 플랫폼이 한 구조에 들어가지 않으므로 준비와 종료라는 라이프사이클 단위로 인터페이스를 잡았다. YouTube 구현은 방송 생성 시 자동 시작과 자동 종료를 켠다. 실측 결과 송출이 시작되면 별도 전환 호출 없이 13.7초 만에 라이브가 되고, 송출을 멈추면 57.6초 뒤 자동으로 종료됐다. 덕분에 상태를 폴링하며 전환을 재시도하는 비동기 처리가 필요 없다. 재사용 스트림은 계정 연결 시점이 아니라 첫 준비 시점에 만든다. 연결 시점에 만들면 라이브가 아직 활성화되지 않은 계정은 연결 자체가 실패하는데, 활성화에는 최대 24시간이 걸리므로 연결과 방송 가능 여부는 분리돼야 한다. 라이브 미활성 계정은 응답 사유가 livePermissionBlocked로 온다. 활성화 전후로 같은 호출을 실행해 확인한 값이며, 함께 오는 도움말 주소를 사용자 안내에 쓴다. --- internal/streaming/provider.go | 41 ++++ internal/streaming/youtube.go | 260 +++++++++++++++++++++++++ internal/streaming/youtube_test.go | 294 +++++++++++++++++++++++++++++ 3 files changed, 595 insertions(+) create mode 100644 internal/streaming/provider.go create mode 100644 internal/streaming/youtube.go create mode 100644 internal/streaming/youtube_test.go diff --git a/internal/streaming/provider.go b/internal/streaming/provider.go new file mode 100644 index 0000000..99a9234 --- /dev/null +++ b/internal/streaming/provider.go @@ -0,0 +1,41 @@ +// Package streaming은 사용자별 송출 대상 플랫폼의 방송 라이프사이클을 다룬다. +// 계정 연결·토큰(internal/auth) 위에서 "방송 준비(Prepare)"와 "중지(Stop)"를 +// 플랫폼 공통 계약으로 추상화한다 — 키 조회 수준으로 추상화하면 방송 생성 +// API가 있는 YouTube와 없는 치지직이 한 구조에 들어가지 않기 때문이다. +package streaming + +import ( + "context" + + "inno-live-server/internal/auth" + + "github.com/google/uuid" +) + +// PrepareOptions는 방송 1회의 표시 속성이다. +type PrepareOptions struct { + // Title은 플랫폼에 표시될 방송 제목. 비면 프로바이더 기본값. + Title string + // Privacy는 공개 범위(public/unlisted/private). 비면 프로바이더 기본값. + Privacy string +} + +// PreparedBroadcast는 송출 시작에 필요한 준비 결과다. +type PreparedBroadcast struct { + Provider auth.StreamingProvider + // IngestURL은 스트림 키가 포함된 완성 URL이다. 로그에 남기지 말 것. + IngestURL string + BroadcastID string + StreamID string +} + +// Provider는 플랫폼별 방송 라이프사이클 계약이다. +type Provider interface { + // Prepare는 플랫폼 쪽 방송 준비를 마치고 송출 대상 ingest URL을 돌려준다. + // YouTube: 재사용 스트림 확보 + broadcast 생성(autoStart/autoStop) + bind. + // 치지직(미래): 스트림 키 조회만으로 완결(방송 생성 API 없음). + Prepare(ctx context.Context, userID uuid.UUID, options PrepareOptions) (PreparedBroadcast, error) + // Stop은 명시적 중지 시 플랫폼 쪽 마무리를 한다. 송출 중단 자체는 + // egress 종료가 담당하므로, 플랫폼 API 호출이 불필요한 구현은 no-op이다. + Stop(ctx context.Context, userID uuid.UUID, prepared PreparedBroadcast) error +} diff --git a/internal/streaming/youtube.go b/internal/streaming/youtube.go new file mode 100644 index 0000000..f76e3d3 --- /dev/null +++ b/internal/streaming/youtube.go @@ -0,0 +1,260 @@ +package streaming + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "inno-live-server/internal/auth" + + "github.com/google/uuid" +) + +const ( + youtubeAPIBase = "https://www.googleapis.com/youtube/v3" + // 기본 방송 속성. Privacy 기본값 public은 "방송 시작을 명시적으로 요청한 + // 사용자의 기대치"를 따른 것으로, start 요청 옵션으로 바꿀 수 있다. + defaultBroadcastTitle = "InnoLive 방송" + defaultBroadcastPrivacy = "public" +) + +var ( + // ErrLiveStreamingBlocked: 채널이 라이브 활성화 대기(신청 후 최대 24시간) + // 이거나 차단된 상태. 실측(2026-08-09 403 → 활성화 후 08-10 200)으로 + // reason=="livePermissionBlocked"가 이 상태의 시그니처임을 확인했다. + ErrLiveStreamingBlocked = errors.New("the YouTube channel is not enabled for live streaming") + // LiveStreamingHelpURL은 위 에러 응답의 extendedHelp 실물값 — 사용자 + // 안내에 그대로 쓴다. + LiveStreamingHelpURL = "https://support.google.com/youtube/answer/2853834" +) + +// AccessTokenProvider는 사용자별 플랫폼 access token 공급 계약이다. +// auth.YouTubeAccessTokenProvider가 이를 만족한다. +type AccessTokenProvider interface { + AccessToken(ctx context.Context, userID uuid.UUID) (string, error) +} + +// YouTubeProvider는 YouTube Live 방송 라이프사이클 구현이다. +// enableAutoStart/enableAutoStop을 사용하므로 transition 호출과 상태 폴링이 +// 없다(실측: 송출 시작 → 13.7초에 live, 중단 → 57.6초에 complete). +type YouTubeProvider struct { + tokens AccessTokenProvider + store auth.StreamingAccountStore + cipher *auth.ProviderTokenCipher + httpClient *http.Client + apiBase string + now func() time.Time +} + +func NewYouTubeProvider(tokens AccessTokenProvider, store auth.StreamingAccountStore, cipher *auth.ProviderTokenCipher) (*YouTubeProvider, error) { + if tokens == nil || store == nil || cipher == nil { + return nil, errors.New("YouTube provider dependencies must not be nil") + } + return &YouTubeProvider{ + tokens: tokens, + store: store, + cipher: cipher, + httpClient: &http.Client{Timeout: 15 * time.Second}, + apiBase: youtubeAPIBase, + now: func() time.Time { return time.Now().UTC() }, + }, nil +} + +// Prepare는 재사용 스트림을 확보하고(첫 방송 때 1회 생성·저장, 이후 재사용), +// autoStart/autoStop broadcast를 만들어 스트림에 바인딩한 뒤 ingest URL을 +// 돌려준다. +func (p *YouTubeProvider) Prepare(ctx context.Context, userID uuid.UUID, options PrepareOptions) (PreparedBroadcast, error) { + accessToken, err := p.tokens.AccessToken(ctx, userID) + if err != nil { + return PreparedBroadcast{}, err + } + account, err := p.store.Get(ctx, userID, auth.StreamingProviderYouTube) + if err != nil { + if errors.Is(err, auth.ErrStreamingAccountNotFound) { + return PreparedBroadcast{}, auth.ErrStreamingNotConnected + } + return PreparedBroadcast{}, err + } + streamID, ingestAddress, streamName, err := p.ensureReusableStream(ctx, accessToken, account) + if err != nil { + return PreparedBroadcast{}, err + } + broadcastID, err := p.insertBroadcast(ctx, accessToken, options) + if err != nil { + return PreparedBroadcast{}, err + } + if err := p.bind(ctx, accessToken, broadcastID, streamID); err != nil { + return PreparedBroadcast{}, err + } + return PreparedBroadcast{ + Provider: auth.StreamingProviderYouTube, + IngestURL: ingestAddress + "/" + streamName, + BroadcastID: broadcastID, + StreamID: streamID, + }, nil +} + +// Stop은 no-op이다: enableAutoStop이 송출 중단(egress 종료) 약 1분 후 +// 방송을 complete로 전환한다(실측 57.6초). 즉시 종료가 필요해지면 +// transition(complete) 호출을 여기 추가한다. +func (p *YouTubeProvider) Stop(context.Context, uuid.UUID, PreparedBroadcast) error { + return nil +} + +// ensureReusableStream은 계정에 저장된 재사용 스트림을 복호화해 돌려주고, +// 없으면 liveStreams.insert(isReusable=true)로 만들어 저장한다. 연결 시점이 +// 아니라 첫 Prepare에서 lazy 생성하는 이유: 연결 서비스(auth)가 Live API에 +// 의존하는 역방향 결합을 피하고, 라이브 미활성 계정도 연결 자체는 성공해야 +// 하기 때문이다. +func (p *YouTubeProvider) ensureReusableStream(ctx context.Context, accessToken string, account auth.StreamingAccount) (streamID, ingestAddress, streamName string, err error) { + if account.StreamID != nil && *account.StreamID != "" && + account.RtmpsIngestionAddress != nil && *account.RtmpsIngestionAddress != "" && + len(account.StreamNameCiphertext) > 0 { + name, err := p.cipher.Decrypt(account.StreamNameCiphertext, account.StreamNameKeyVersion) + if err != nil { + return "", "", "", fmt.Errorf("decrypt stored stream name: %w", err) + } + return *account.StreamID, *account.RtmpsIngestionAddress, name, nil + } + + payload := map[string]any{ + "snippet": map[string]any{"title": "InnoLive stream"}, + "cdn": map[string]any{"ingestionType": "rtmp", "resolution": "variable", "frameRate": "variable"}, + "contentDetails": map[string]any{"isReusable": true}, + } + var response struct { + ID string `json:"id"` + CDN struct { + IngestionInfo struct { + StreamName string `json:"streamName"` + IngestionAddress string `json:"ingestionAddress"` + BackupIngestionAddress string `json:"backupIngestionAddress"` + RtmpsIngestionAddress string `json:"rtmpsIngestionAddress"` + RtmpsBackupIngestionAddress string `json:"rtmpsBackupIngestionAddress"` + } `json:"ingestionInfo"` + } `json:"cdn"` + } + if err := p.post(ctx, accessToken, "/liveStreams?part=snippet,cdn,contentDetails", payload, &response); err != nil { + return "", "", "", err + } + info := response.CDN.IngestionInfo + if response.ID == "" || info.RtmpsIngestionAddress == "" || info.StreamName == "" { + return "", "", "", errors.New("liveStreams.insert response is missing ingestion info") + } + ciphertext, version, err := p.cipher.Encrypt(info.StreamName) + if err != nil { + return "", "", "", err + } + if err := p.store.UpdateStreamInfo(ctx, account.ID, auth.StreamInfo{ + StreamID: response.ID, + IngestionAddress: info.IngestionAddress, + BackupIngestionAddress: info.BackupIngestionAddress, + RtmpsIngestionAddress: info.RtmpsIngestionAddress, + RtmpsBackupIngestionAddress: info.RtmpsBackupIngestionAddress, + StreamNameCiphertext: ciphertext, + StreamNameKeyVersion: version, + }); err != nil { + return "", "", "", fmt.Errorf("persist reusable stream info: %w", err) + } + return response.ID, info.RtmpsIngestionAddress, info.StreamName, nil +} + +func (p *YouTubeProvider) insertBroadcast(ctx context.Context, accessToken string, options PrepareOptions) (string, error) { + title := strings.TrimSpace(options.Title) + if title == "" { + title = defaultBroadcastTitle + } + privacy := strings.TrimSpace(options.Privacy) + if privacy == "" { + privacy = defaultBroadcastPrivacy + } + payload := map[string]any{ + "snippet": map[string]any{ + "title": title, + // autoStart를 쓰므로 예정 시각은 형식 요건일 뿐이다(실측: 예정 + // 시각과 무관하게 송출 시작 13.7초 만에 live 전환). + "scheduledStartTime": p.now().Format(time.RFC3339), + }, + "status": map[string]any{ + "privacyStatus": privacy, + "selfDeclaredMadeForKids": false, + }, + "contentDetails": map[string]any{ + "enableAutoStart": true, + "enableAutoStop": true, + "monitorStream": map[string]any{"enableMonitorStream": false}, + }, + } + var response struct { + ID string `json:"id"` + } + if err := p.post(ctx, accessToken, "/liveBroadcasts?part=snippet,contentDetails,status", payload, &response); err != nil { + return "", err + } + if response.ID == "" { + return "", errors.New("liveBroadcasts.insert response has no id") + } + return response.ID, nil +} + +func (p *YouTubeProvider) bind(ctx context.Context, accessToken, broadcastID, streamID string) error { + path := fmt.Sprintf("/liveBroadcasts/bind?id=%s&streamId=%s&part=id,contentDetails", broadcastID, streamID) + return p.post(ctx, accessToken, path, nil, &struct{}{}) +} + +func (p *YouTubeProvider) post(ctx context.Context, accessToken, path string, payload, target any) error { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase+path, body) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+accessToken) + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := p.httpClient.Do(request) + if err != nil { + return fmt.Errorf("request YouTube Live API: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return decodeYouTubeAPIError(response) + } + return json.NewDecoder(io.LimitReader(response.Body, 256<<10)).Decode(target) +} + +// decodeYouTubeAPIError는 오류 본문의 reason으로 도메인 에러를 구분한다. +// 본문 구조는 2026-08-09 실측 응답(error.errors[].reason)을 따른다. +func decodeYouTubeAPIError(response *http.Response) error { + var payload struct { + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Errors []struct { + Reason string `json:"reason"` + } `json:"errors"` + } `json:"error"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 32<<10)).Decode(&payload); err != nil { + return fmt.Errorf("YouTube Live API returned HTTP %d", response.StatusCode) + } + for _, item := range payload.Error.Errors { + if item.Reason == "livePermissionBlocked" { + return ErrLiveStreamingBlocked + } + } + return fmt.Errorf("YouTube Live API returned HTTP %d (%s)", response.StatusCode, payload.Error.Message) +} diff --git a/internal/streaming/youtube_test.go b/internal/streaming/youtube_test.go new file mode 100644 index 0000000..55ef621 --- /dev/null +++ b/internal/streaming/youtube_test.go @@ -0,0 +1,294 @@ +package streaming + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "inno-live-server/internal/auth" + + "github.com/google/uuid" +) + +type stubTokens struct{ token string } + +func (s stubTokens) AccessToken(context.Context, uuid.UUID) (string, error) { + return s.token, nil +} + +type memoryStore struct { + mu sync.Mutex + accounts map[uuid.UUID]auth.StreamingAccount +} + +func newMemoryStore() *memoryStore { + return &memoryStore{accounts: make(map[uuid.UUID]auth.StreamingAccount)} +} + +func (s *memoryStore) Upsert(_ context.Context, account auth.StreamingAccount) error { + s.mu.Lock() + defer s.mu.Unlock() + if account.ID == uuid.Nil { + account.ID = uuid.New() + } + s.accounts[account.ID] = account + return nil +} + +func (s *memoryStore) Get(_ context.Context, userID uuid.UUID, provider auth.StreamingProvider) (auth.StreamingAccount, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, account := range s.accounts { + if account.UserID == userID && account.Provider == provider { + return account, nil + } + } + return auth.StreamingAccount{}, auth.ErrStreamingAccountNotFound +} + +func (s *memoryStore) UpdateRefreshToken(_ context.Context, id uuid.UUID, ciphertext []byte, version *int16, expiresAt *time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + account, ok := s.accounts[id] + if !ok { + return auth.ErrStreamingAccountNotFound + } + account.RefreshTokenCiphertext = ciphertext + account.TokenKeyVersion = version + account.RefreshTokenExpiresAt = expiresAt + s.accounts[id] = account + return nil +} + +func (s *memoryStore) UpdateStreamInfo(_ context.Context, id uuid.UUID, info auth.StreamInfo) error { + s.mu.Lock() + defer s.mu.Unlock() + account, ok := s.accounts[id] + if !ok { + return auth.ErrStreamingAccountNotFound + } + account.StreamID = &info.StreamID + account.IngestionAddress = &info.IngestionAddress + account.BackupIngestionAddress = &info.BackupIngestionAddress + account.RtmpsIngestionAddress = &info.RtmpsIngestionAddress + account.RtmpsBackupIngestionAddress = &info.RtmpsBackupIngestionAddress + account.StreamNameCiphertext = info.StreamNameCiphertext + account.StreamNameKeyVersion = info.StreamNameKeyVersion + s.accounts[id] = account + return nil +} + +func testCipher(t *testing.T) *auth.ProviderTokenCipher { + t.Helper() + cipher, err := auth.NewProviderTokenCipherFromBase64(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32))) + if err != nil { + t.Fatal(err) + } + return cipher +} + +// youtubeAPIStub은 Live API 3종(liveStreams.insert / liveBroadcasts.insert / +// bind)을 흉내 내며 요청을 기록한다. +type youtubeAPIStub struct { + mu sync.Mutex + streamInserts int + broadcastInserts int + binds int + lastBroadcast map[string]any + bindQuery string + blockLive bool +} + +func (s *youtubeAPIStub) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + switch { + case strings.HasPrefix(r.URL.Path, "/liveStreams"): + s.streamInserts++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"stream-id-1","cdn":{"ingestionInfo":{ + "streamName":"secret-stream-name", + "ingestionAddress":"rtmp://a.rtmp.youtube.com/live2", + "backupIngestionAddress":"rtmp://b.rtmp.youtube.com/live2?backup=1", + "rtmpsIngestionAddress":"rtmps://a.rtmps.youtube.com/live2", + "rtmpsBackupIngestionAddress":"rtmps://b.rtmps.youtube.com/live2?backup=1"}}}`)) + case strings.HasPrefix(r.URL.Path, "/liveBroadcasts/bind"): + s.binds++ + s.bindQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"broadcast-id-1"}`)) + case strings.HasPrefix(r.URL.Path, "/liveBroadcasts"): + if s.blockLive { + // 라이브 미활성 채널의 실측 응답 형태(2026-08-09). + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":{"code":403,"message":"The user is blocked from live streaming.","errors":[{"message":"The user is blocked from live streaming.","domain":"youtube.liveBroadcast","reason":"livePermissionBlocked","extendedHelp":"https://support.google.com/youtube/answer/2853834"}]}}`)) + return + } + s.broadcastInserts++ + if err := json.NewDecoder(r.Body).Decode(&s.lastBroadcast); err != nil { + t.Errorf("decode broadcast payload: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"broadcast-id-1"}`)) + default: + t.Errorf("unexpected request path %q", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + }) +} + +func testProviderWith(t *testing.T, stub *youtubeAPIStub, store auth.StreamingAccountStore) *YouTubeProvider { + t.Helper() + server := httptest.NewServer(stub.handler(t)) + t.Cleanup(server.Close) + provider, err := NewYouTubeProvider(stubTokens{token: "at-value"}, store, testCipher(t)) + if err != nil { + t.Fatal(err) + } + provider.apiBase = server.URL + return provider +} + +func connectedAccount(t *testing.T, store *memoryStore, userID uuid.UUID) auth.StreamingAccount { + t.Helper() + account := auth.StreamingAccount{ + ID: uuid.New(), + UserID: userID, + Provider: auth.StreamingProviderYouTube, + ChannelID: "UCabc", + } + if err := store.Upsert(context.Background(), account); err != nil { + t.Fatal(err) + } + return account +} + +func TestPrepareCreatesReusableStreamOnceAndBindsBroadcast(t *testing.T) { + stub := &youtubeAPIStub{} + store := newMemoryStore() + userID := uuid.New() + connectedAccount(t, store, userID) + provider := testProviderWith(t, stub, store) + + first, err := provider.Prepare(context.Background(), userID, PrepareOptions{}) + if err != nil { + t.Fatal(err) + } + if first.IngestURL != "rtmps://a.rtmps.youtube.com/live2/secret-stream-name" { + t.Fatalf("IngestURL = %q", first.IngestURL) + } + if first.BroadcastID != "broadcast-id-1" || first.StreamID != "stream-id-1" { + t.Fatalf("prepared = %+v", first) + } + + // 프리로딩 저장 검증: streamName은 암호문으로만 저장돼야 한다. + account, err := store.Get(context.Background(), userID, auth.StreamingProviderYouTube) + if err != nil { + t.Fatal(err) + } + if account.StreamID == nil || *account.StreamID != "stream-id-1" { + t.Fatalf("stored stream id = %v", account.StreamID) + } + if bytes.Contains(account.StreamNameCiphertext, []byte("secret-stream-name")) { + t.Fatal("stream name stored in plaintext") + } + if account.RtmpsIngestionAddress == nil || *account.RtmpsIngestionAddress != "rtmps://a.rtmps.youtube.com/live2" { + t.Fatalf("stored rtmps address = %v", account.RtmpsIngestionAddress) + } + + // 두 번째 방송: 저장된 스트림을 재사용해야 한다(liveStreams.insert 1회 유지). + second, err := provider.Prepare(context.Background(), userID, PrepareOptions{}) + if err != nil { + t.Fatal(err) + } + if second.IngestURL != first.IngestURL || second.StreamID != first.StreamID { + t.Fatalf("second prepare = %+v, want stream reuse", second) + } + stub.mu.Lock() + defer stub.mu.Unlock() + if stub.streamInserts != 1 { + t.Fatalf("liveStreams.insert calls = %d, want 1 (reusable)", stub.streamInserts) + } + if stub.broadcastInserts != 2 || stub.binds != 2 { + t.Fatalf("broadcast/bind calls = %d/%d, want 2/2", stub.broadcastInserts, stub.binds) + } + if !strings.Contains(stub.bindQuery, "id=broadcast-id-1") || !strings.Contains(stub.bindQuery, "streamId=stream-id-1") { + t.Fatalf("bind query = %q", stub.bindQuery) + } +} + +func TestPrepareBroadcastPayloadUsesAutoStartAndDefaults(t *testing.T) { + stub := &youtubeAPIStub{} + store := newMemoryStore() + userID := uuid.New() + connectedAccount(t, store, userID) + provider := testProviderWith(t, stub, store) + + if _, err := provider.Prepare(context.Background(), userID, PrepareOptions{}); err != nil { + t.Fatal(err) + } + stub.mu.Lock() + payload := stub.lastBroadcast + stub.mu.Unlock() + + content := payload["contentDetails"].(map[string]any) + if content["enableAutoStart"] != true || content["enableAutoStop"] != true { + t.Fatalf("contentDetails = %v, want autoStart/autoStop true", content) + } + monitor := content["monitorStream"].(map[string]any) + if monitor["enableMonitorStream"] != false { + t.Fatalf("monitorStream = %v", monitor) + } + status := payload["status"].(map[string]any) + if status["privacyStatus"] != defaultBroadcastPrivacy { + t.Fatalf("privacyStatus = %v, want default %q", status["privacyStatus"], defaultBroadcastPrivacy) + } + snippet := payload["snippet"].(map[string]any) + if snippet["title"] != defaultBroadcastTitle { + t.Fatalf("title = %v, want default", snippet["title"]) + } + + // 옵션 오버라이드. + if _, err := provider.Prepare(context.Background(), userID, PrepareOptions{Title: "내 방송", Privacy: "unlisted"}); err != nil { + t.Fatal(err) + } + stub.mu.Lock() + payload = stub.lastBroadcast + stub.mu.Unlock() + if payload["snippet"].(map[string]any)["title"] != "내 방송" { + t.Fatalf("title override failed: %v", payload["snippet"]) + } + if payload["status"].(map[string]any)["privacyStatus"] != "unlisted" { + t.Fatalf("privacy override failed: %v", payload["status"]) + } +} + +func TestPrepareMapsLivePermissionBlocked(t *testing.T) { + stub := &youtubeAPIStub{blockLive: true} + store := newMemoryStore() + userID := uuid.New() + connectedAccount(t, store, userID) + provider := testProviderWith(t, stub, store) + + _, err := provider.Prepare(context.Background(), userID, PrepareOptions{}) + if !errors.Is(err, ErrLiveStreamingBlocked) { + t.Fatalf("error = %v, want ErrLiveStreamingBlocked", err) + } +} + +func TestPrepareRequiresConnection(t *testing.T) { + provider := testProviderWith(t, &youtubeAPIStub{}, newMemoryStore()) + _, err := provider.Prepare(context.Background(), uuid.New(), PrepareOptions{}) + if !errors.Is(err, auth.ErrStreamingNotConnected) { + t.Fatalf("error = %v, want ErrStreamingNotConnected", err) + } +} From abae15a72b95c5db17660f284399834e04f86009 Mon Sep 17 00:00:00 2001 From: itzjb Date: Mon, 10 Aug 2026 17:30:36 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20=EC=86=A1=EC=B6=9C=20=EC=8B=9C=EC=9E=91=C2=B7?= =?UTF-8?q?=EC=A4=91=EC=A7=80=20=EB=B0=B0=EC=84=A0=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=8A=A4=ED=8A=B8=EB=A6=BC=20=ED=82=A4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 송출 대상이 서버 전역 환경변수 하나로 고정돼 있어 모든 세션이 운영자 채널 하나로만 나갔고, 동시 세션이 둘 이상이면 같은 키로 충돌했다. 이제 세션 소유자가 연결한 계정에서 대상을 가져오고, 송출 개시는 명시적 요청으로만 이루어진다. 뷰어 송출은 그대로 둔 채 플랫폼 송출만 끄는 것도 가능해졌다. 시작 요청은 트랙이 도착한 뒤에 오는데 기존 파이프라인은 egress를 인자로 고정해 잡고 있어 실행 중에 꽂을 방법이 없었다. 파이프라인이 슬롯을 통해 참조하도록 바꿔 시작이 채우고 중지가 비운다. 프레임당 원자적 읽기 한 번이 추가 비용이다. ingest 주소는 API 응답값을 쓰므로 하드코딩 상수가 사라졌고, 그 과정에서 평문 RTMP 대신 RTMPS로 전환된다. 플랫폼이 서버에 구성되지 않은 경우 기존 501 응답을 유지해 인증을 끄고 도는 벤치마크와 미설정 배포의 동작을 보존한다. --- .env.example | 5 +- cmd/server/main.go | 17 ++- internal/config/config.go | 2 - internal/media/track.go | 34 ++++- internal/server/server.go | 70 ++++++++- internal/server/server_test.go | 2 +- internal/server/session_auth_test.go | 2 +- internal/server/stream_test.go | 207 +++++++++++++++++++++++++++ internal/session/manager.go | 119 +++++++++++---- internal/session/manager_test.go | 116 +++++++++------ 10 files changed, 481 insertions(+), 93 deletions(-) create mode 100644 internal/server/stream_test.go diff --git a/.env.example b/.env.example index 79eab67..08a0b31 100644 --- a/.env.example +++ b/.env.example @@ -43,8 +43,9 @@ WEBRTC_UDP_PORT_MIN=50002 WEBRTC_UDP_PORT_MAX=50020 WEBRTC_DISCONNECTED_GRACE=10s -# ── YouTube RTMP egress (발행자 오디오) ──────────────── -YOUTUBE_STREAM_KEY= +# ── RTMP egress (발행자 오디오) ──────────────── +# 송출 대상은 이제 서버 전역 키가 아니라 사용자별 연결 계정(/auth/youtube/connect)에서 +# 나오며, 송출 개시는 POST /sessions/{id}/stream/start 로만 이루어집니다. ENABLE_AUDIO_EGRESS=false # 발행자 마이크(Opus)를 함께 송출. off면 무음 EGRESS_LATENCY_LOG=false # ingest→egress 지연 p50/p95/max 로깅(측정용) EGRESS_AUDIO_OFFSET_MS=0 # A/V 싱크 보정(오디오 -itsoffset, ms). 양수=오디오 지연 diff --git a/cmd/server/main.go b/cmd/server/main.go index 49b30c1..630504e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "inno-live-server/internal/origin" "inno-live-server/internal/server" "inno-live-server/internal/session" + "inno-live-server/internal/streaming" ) func main() { @@ -318,6 +319,7 @@ func main() { // 생성됐을 수 있으므로 없을 때만 만든다 — Apple 없이 YouTube만 켠 // 배포에서도 refresh token 암호화가 성립해야 한다. var youtubeConnect *auth.YouTubeConnectService + streamingProviders := map[auth.StreamingProvider]streaming.Provider{} if youtubeOAuthConfig.Enabled() { if providerTokenCipher == nil { providerTokenCipher, err = auth.NewProviderTokenCipherFromBase64(os.Getenv("AUTH_PROVIDER_TOKEN_ENCRYPTION_KEY_BASE64")) @@ -331,9 +333,10 @@ func main() { logger.Error("create YouTube OAuth client failed", "error", err) os.Exit(2) } + streamingAccountStore := auth.NewGormStreamingAccountStore(databaseConnection.DB) youtubeConnect, err = auth.NewYouTubeConnectService( youtubeOAuthClient, - auth.NewGormStreamingAccountStore(databaseConnection.DB), + streamingAccountStore, userStatusChecker, providerTokenCipher, ) @@ -341,6 +344,17 @@ func main() { logger.Error("create YouTube connect service failed", "error", err) os.Exit(2) } + youtubeTokens, err := auth.NewYouTubeAccessTokenProvider(youtubeOAuthClient, streamingAccountStore, providerTokenCipher) + if err != nil { + logger.Error("create YouTube access token provider failed", "error", err) + os.Exit(2) + } + youtubeProvider, err := streaming.NewYouTubeProvider(youtubeTokens, streamingAccountStore, providerTokenCipher) + if err != nil { + logger.Error("create YouTube streaming provider failed", "error", err) + os.Exit(2) + } + streamingProviders[auth.StreamingProviderYouTube] = youtubeProvider } // INNOLIVE_REQUIRE_SESSION_AUTH=false is the explicit local-development // escape hatch (loud warning above). Extend it to user auth as well so @@ -362,6 +376,7 @@ func main() { aiPool, originConfig, requireUser, + streamingProviders, authenticateUser, ) diff --git a/internal/config/config.go b/internal/config/config.go index af54122..d9561d2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,7 +69,6 @@ type Config struct { UDPMuxPort int DisconnectedGracePeriod time.Duration FrameQueueSize int - YoutubeStreamKey string EnableAudioEgress bool EgressLatencyLog bool EgressAudioOffset time.Duration @@ -111,7 +110,6 @@ func Load() (Config, error) { AITimeout: envDuration("AI_GRPC_TIMEOUT", 5*time.Second), PrivacyFixedDelay: envDurationWithMillisecondsAlias("AI_PRIVACY_FIXED_DELAY", "AI_PRIVACY_FIXED_DELAY_MS", 20*time.Millisecond), FrameQueueSize: envInt("AI_FRAME_QUEUE_SIZE", 2), - YoutubeStreamKey: strings.TrimSpace(os.Getenv("YOUTUBE_STREAM_KEY")), EnableAudioEgress: envBool("ENABLE_AUDIO_EGRESS", false), EgressLatencyLog: envBool("EGRESS_LATENCY_LOG", false), EgressAudioOffset: time.Duration(envInt("EGRESS_AUDIO_OFFSET_MS", 0)) * time.Millisecond, diff --git a/internal/media/track.go b/internal/media/track.go index f30b13e..05a7065 100644 --- a/internal/media/track.go +++ b/internal/media/track.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "sync" + "sync/atomic" "time" "inno-live-server/internal/config" @@ -41,6 +42,29 @@ type frame struct { height uint16 } +// EgressSlot은 실행 중인 파이프라인에 egress를 나중에 꽂거나 뗄 수 있게 하는 +// 홀더다. 명시적 송출 시작(#83)은 트랙 도착(파이프라인 기동) 이후에 오므로, +// 파이프라인은 egress를 직접 들지 않고 이 슬롯을 통해서만 참조한다. +type EgressSlot struct { + current atomic.Pointer[RTMPEgress] +} + +func NewEgressSlot() *EgressSlot { return &EgressSlot{} } + +// Set은 활성 egress를 교체한다. +func (s *EgressSlot) Set(egress *RTMPEgress) { s.current.Store(egress) } + +// Clear는 슬롯을 비운다. 이후 파이프라인 프레임은 egress로 가지 않는다. +func (s *EgressSlot) Clear() { s.current.Store(nil) } + +// Load는 현재 활성 egress를 돌려준다(슬롯이 nil이거나 비었으면 nil). +func (s *EgressSlot) Load() *RTMPEgress { + if s == nil { + return nil + } + return s.current.Load() +} + type rtpSequenceObservation struct { gap uint64 recovered bool @@ -166,7 +190,7 @@ func RunTrack( local *webrtc.TrackLocalStaticSample, processor *Processor, transcoder *FFmpegTranscoder, - egress *RTMPEgress, + egress *EgressSlot, registry *metrics.Registry, mode config.PrivacyMode, queueSize int, @@ -182,7 +206,7 @@ func runTranscodedTrack( local *webrtc.TrackLocalStaticSample, processor *Processor, transcoder *FFmpegTranscoder, - egress *RTMPEgress, + egress *EgressSlot, registry *metrics.Registry, mode config.PrivacyMode, queueSize int, @@ -324,7 +348,7 @@ func processImages( processor *Processor, decoded <-chan frame, processed chan<- frame, - egress *RTMPEgress, + egress *EgressSlot, registry *metrics.Registry, mode config.PrivacyMode, ) { @@ -348,8 +372,8 @@ func processImages( registry.IncFrameProcessed(string(mode)) item.data = output item.stageAt = time.Now() - if egress != nil { - egress.Enqueue(item) + if sink := egress.Load(); sink != nil { + sink.Enqueue(item) } select { case processed <- item: diff --git a/internal/server/server.go b/internal/server/server.go index 3f2bf13..0b22ee1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,7 @@ import ( "inno-live-server/internal/metrics" "inno-live-server/internal/origin" "inno-live-server/internal/session" + "inno-live-server/internal/streaming" ) const maxJSONBody = 1 << 20 @@ -33,6 +34,7 @@ type Server struct { ai *ai.Pool references *referenceStore origins origin.Config + streaming map[auth.StreamingProvider]streaming.Provider authenticateUser func(context.Context, string) (uuid.UUID, error) handler http.Handler } @@ -45,6 +47,7 @@ func New( aiPool *ai.Pool, origins origin.Config, requireUser func(http.Handler) http.Handler, + streamingProviders map[auth.StreamingProvider]streaming.Provider, userAuthenticators ...func(context.Context, string) (uuid.UUID, error), ) *Server { if requireUser == nil { @@ -58,6 +61,7 @@ func New( ai: aiPool, references: newReferenceStore(cfg.ReferenceStorePath, cfg.AIMeImagePath != ""), origins: origins, + streaming: streamingProviders, } if len(userAuthenticators) > 0 { s.authenticateUser = userAuthenticators[0] @@ -176,16 +180,72 @@ func (s *Server) handleDeleteSession(w http.ResponseWriter, _ *http.Request, liv w.WriteHeader(http.StatusNoContent) } -func (s *Server) handleStartStream(w http.ResponseWriter, _ *http.Request, liveSession *session.Session) { - response := liveSession.Response() - if response.Media.RawVideoTrack == nil { - writeError(w, apiError{Status: http.StatusConflict, Code: "conflict", Message: "Cannot start stream before a video track is available.", Details: map[string]any{"session_id": liveSession.ID}}) +// handleStartStream은 명시적 송출 시작(#83)이다: 요청 사용자의 연결된 플랫폼 +// 계정으로 방송을 준비(Prepare)하고, 세션의 처리 출력에 egress를 붙인다. +func (s *Server) handleStartStream(w http.ResponseWriter, r *http.Request, liveSession *session.Session) { + request := struct { + Provider string `json:"provider"` + Title string `json:"title"` + Privacy string `json:"privacy"` + }{} + r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody) + if err := decodeOptionalJSON(r.Body, &request); err != nil { + writeError(w, badRequest("Invalid stream start request.", map[string]any{"error": err.Error()})) + return + } + providerName := auth.StreamingProvider(strings.TrimSpace(request.Provider)) + if providerName == "" { + providerName = auth.StreamingProviderYouTube + } + provider := s.streaming[providerName] + if provider == nil { + // 플랫폼 송출이 조립되지 않은 배포(자격증명 미설정·벤치)에서는 종전 + // 계약(501)을 유지한다. + writeError(w, apiError{Status: http.StatusNotImplemented, Code: "not_supported", Message: "Streaming to this platform is not configured on the server.", Details: map[string]any{"provider": providerName}}) + return + } + prepared, err := provider.Prepare(r.Context(), liveSession.UserID, streaming.PrepareOptions{ + Title: request.Title, + Privacy: request.Privacy, + }) + if err != nil { + switch { + case errors.Is(err, auth.ErrStreamingNotConnected): + writeError(w, apiError{Status: http.StatusConflict, Code: "streaming_not_connected", Message: "Connect a streaming account before starting a stream.", Details: map[string]any{"provider": providerName}}) + case errors.Is(err, streaming.ErrLiveStreamingBlocked): + writeError(w, apiError{Status: http.StatusForbidden, Code: "live_streaming_blocked", Message: "The channel is not enabled for live streaming. Enabling can take up to 24 hours.", Details: map[string]any{"help_url": streaming.LiveStreamingHelpURL}}) + default: + s.logger.Error("prepare platform broadcast failed", "session_id", liveSession.ID, "provider", providerName, "error", err) + writeError(w, apiError{Status: http.StatusBadGateway, Code: "streaming_prepare_failed", Message: "The streaming platform could not prepare the broadcast."}) + } return } - writeError(w, apiError{Status: http.StatusNotImplemented, Code: "not_supported", Message: "RTMP publishing is not supported by the media server."}) + if _, err := s.sessions.StartStream(liveSession.ID, prepared.IngestURL); err != nil { + switch { + case errors.Is(err, session.ErrNoVideoTrack): + writeError(w, apiError{Status: http.StatusConflict, Code: "conflict", Message: "Cannot start stream before a video track is available.", Details: map[string]any{"session_id": liveSession.ID}}) + case errors.Is(err, session.ErrStreamActive): + writeError(w, apiError{Status: http.StatusConflict, Code: "stream_already_active", Message: "The stream is already active.", Details: map[string]any{"session_id": liveSession.ID}}) + default: + writeSessionError(w, err, liveSession.ID) + } + return + } + writeJSON(w, http.StatusOK, liveSession.Response()) } +// handleStopStream은 egress만 종료한다(뷰어 송출·세션은 유지). 플랫폼 쪽 +// 방송 종료는 autoStop이 담당하므로(송출 중단 약 1분 후 반영) 플랫폼 API +// 호출이 없다. func (s *Server) handleStopStream(w http.ResponseWriter, _ *http.Request, liveSession *session.Session) { + if _, err := s.sessions.StopStream(liveSession.ID); err != nil { + if errors.Is(err, session.ErrStreamNotActive) { + writeError(w, apiError{Status: http.StatusConflict, Code: "stream_not_active", Message: "The stream is not active.", Details: map[string]any{"session_id": liveSession.ID}}) + return + } + writeSessionError(w, err, liveSession.ID) + return + } writeJSON(w, http.StatusOK, liveSession.Response().Stream) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 942fa98..170693c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -623,7 +623,7 @@ func newTestApplicationWithUserMiddleware(t *testing.T, requireUser func(http.Ha if err != nil { t.Fatal(err) } - return New(cfg, logger, registry, manager, nil, origins, requireUser, authenticateUsers...), manager + return New(cfg, logger, registry, manager, nil, origins, requireUser, nil, authenticateUsers...), manager } // createTestSession creates a session and returns its response plus the diff --git a/internal/server/session_auth_test.go b/internal/server/session_auth_test.go index fd64b81..5d7fb9d 100644 --- a/internal/server/session_auth_test.go +++ b/internal/server/session_auth_test.go @@ -124,7 +124,7 @@ func TestSessionAuthDisabledBypass(t *testing.T) { if err != nil { t.Fatal(err) } - httpServer := httptest.NewServer(New(cfg, logger, registry, manager, nil, origins, nil).Handler()) + httpServer := httptest.NewServer(New(cfg, logger, registry, manager, nil, origins, nil, nil).Handler()) defer httpServer.Close() created, _ := createTestSession(t, httpServer.URL, nil) diff --git a/internal/server/stream_test.go b/internal/server/stream_test.go new file mode 100644 index 0000000..44abe09 --- /dev/null +++ b/internal/server/stream_test.go @@ -0,0 +1,207 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "inno-live-server/internal/auth" + "inno-live-server/internal/config" + "inno-live-server/internal/metrics" + "inno-live-server/internal/origin" + "inno-live-server/internal/session" + "inno-live-server/internal/streaming" +) + +type stubStreamingProvider struct { + prepared streaming.PreparedBroadcast + prepareErr error +} + +func (s *stubStreamingProvider) Prepare(context.Context, uuid.UUID, streaming.PrepareOptions) (streaming.PreparedBroadcast, error) { + return s.prepared, s.prepareErr +} + +func (s *stubStreamingProvider) Stop(context.Context, uuid.UUID, streaming.PreparedBroadcast) error { + return nil +} + +func newStreamTestApplication(t *testing.T, providers map[auth.StreamingProvider]streaming.Provider) *httptest.Server { + t.Helper() + cfg := config.Config{ + HTTPAddr: ":0", + PrivacyMode: config.PrivacyModeBypass, + PrivacyFixedDelay: time.Millisecond, + AITimeout: time.Second, + FFmpegPath: "ffmpeg", + UDPPortMin: 41200, + UDPPortMax: 41300, + DisconnectedGracePeriod: 100 * time.Millisecond, + FrameQueueSize: 2, + RequireSessionAuth: true, + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + registry := metrics.New() + manager, err := session.NewManager(cfg, logger, registry, nil, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(manager.CloseAll) + origins, err := origin.NewConfig(false, []string{"https://client.invalid"}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(New(cfg, logger, registry, manager, nil, origins, nil, providers).Handler()) + t.Cleanup(server.Close) + return server +} + +func startStream(t *testing.T, baseURL, sessionID, ownerToken, body string) (*http.Response, map[string]any) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, baseURL+"/sessions/"+sessionID+"/stream/start", bytes.NewBufferString(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Session-Owner-Token", ownerToken) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { response.Body.Close() }) + payload := map[string]any{} + _ = json.NewDecoder(response.Body).Decode(&payload) + return response, payload +} + +func streamErrorCode(payload map[string]any) string { + errorBody, _ := payload["error"].(map[string]any) + code, _ := errorBody["code"].(string) + return code +} + +// TestStartStreamNotConfigured: 플랫폼 송출이 조립되지 않은 배포(자격증명 +// 미설정·벤치)에서는 종전 계약(501)이 유지돼야 한다. +func TestStartStreamNotConfigured(t *testing.T) { + server := newStreamTestApplication(t, nil) + created, ownerToken := createTestSession(t, server.URL, nil) + + response, payload := startStream(t, server.URL, created.SessionID, ownerToken, `{}`) + if response.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501", response.StatusCode) + } + if streamErrorCode(payload) != "not_supported" { + t.Fatalf("error code = %q", streamErrorCode(payload)) + } +} + +func TestStartStreamMapsProviderErrors(t *testing.T) { + cases := []struct { + name string + provider *stubStreamingProvider + wantStatus int + wantCode string + }{ + { + name: "not connected", + provider: &stubStreamingProvider{prepareErr: auth.ErrStreamingNotConnected}, + wantStatus: http.StatusConflict, + wantCode: "streaming_not_connected", + }, + { + name: "live blocked", + provider: &stubStreamingProvider{prepareErr: streaming.ErrLiveStreamingBlocked}, + wantStatus: http.StatusForbidden, + wantCode: "live_streaming_blocked", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := newStreamTestApplication(t, map[auth.StreamingProvider]streaming.Provider{ + auth.StreamingProviderYouTube: tc.provider, + }) + created, ownerToken := createTestSession(t, server.URL, nil) + response, payload := startStream(t, server.URL, created.SessionID, ownerToken, `{}`) + if response.StatusCode != tc.wantStatus { + t.Fatalf("status = %d, want %d (payload %v)", response.StatusCode, tc.wantStatus, payload) + } + if streamErrorCode(payload) != tc.wantCode { + t.Fatalf("error code = %q, want %q", streamErrorCode(payload), tc.wantCode) + } + if tc.wantCode == "live_streaming_blocked" { + details, _ := payload["error"].(map[string]any)["details"].(map[string]any) + if details["help_url"] != streaming.LiveStreamingHelpURL { + t.Fatalf("help_url = %v, want extendedHelp URL", details["help_url"]) + } + } + }) + } +} + +// TestStartStreamRequiresTrackAfterPrepare: Prepare가 성공해도 비디오 트랙이 +// 없으면 종전 409 계약을 유지해야 한다. +func TestStartStreamRequiresTrackAfterPrepare(t *testing.T) { + provider := &stubStreamingProvider{prepared: streaming.PreparedBroadcast{ + Provider: auth.StreamingProviderYouTube, + IngestURL: "rtmps://a.rtmps.youtube.com/live2/secret", + }} + server := newStreamTestApplication(t, map[auth.StreamingProvider]streaming.Provider{ + auth.StreamingProviderYouTube: provider, + }) + created, ownerToken := createTestSession(t, server.URL, nil) + + response, payload := startStream(t, server.URL, created.SessionID, ownerToken, `{}`) + if response.StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409 (payload %v)", response.StatusCode, payload) + } + if streamErrorCode(payload) != "conflict" { + t.Fatalf("error code = %q, want conflict", streamErrorCode(payload)) + } +} + +func TestStartStreamRejectsUnknownProvider(t *testing.T) { + server := newStreamTestApplication(t, map[auth.StreamingProvider]streaming.Provider{ + auth.StreamingProviderYouTube: &stubStreamingProvider{}, + }) + created, ownerToken := createTestSession(t, server.URL, nil) + + response, payload := startStream(t, server.URL, created.SessionID, ownerToken, `{"provider":"soop"}`) + if response.StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501 for unregistered provider", response.StatusCode) + } + if streamErrorCode(payload) != "not_supported" { + t.Fatalf("error code = %q", streamErrorCode(payload)) + } +} + +func TestStopStreamNotActive(t *testing.T) { + server := newStreamTestApplication(t, nil) + created, ownerToken := createTestSession(t, server.URL, nil) + + request, err := http.NewRequest(http.MethodPost, server.URL+"/sessions/"+created.SessionID+"/stream/stop", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Session-Owner-Token", ownerToken) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409", response.StatusCode) + } + payload := map[string]any{} + _ = json.NewDecoder(response.Body).Decode(&payload) + if streamErrorCode(payload) != "stream_not_active" { + t.Fatalf("error code = %q, want stream_not_active", streamErrorCode(payload)) + } +} diff --git a/internal/session/manager.go b/internal/session/manager.go index 333d9d3..402953f 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -25,10 +25,11 @@ import ( var ( ErrNotFound = errors.New("session not found") ErrCapacityExceeded = errors.New("session capacity exceeded") + ErrNoVideoTrack = errors.New("no video track is available") + ErrStreamActive = errors.New("stream egress is already active") + ErrStreamNotActive = errors.New("stream egress is not active") ) -const youtubeIngestURL = "rtmp://a.rtmp.youtube.com/live2/" - type Timing struct { SessionToOfferMS *int64 `json:"session_to_offer_ms"` OfferToAnswerMS *int64 `json:"offer_to_answer_ms"` @@ -101,13 +102,16 @@ type Session struct { processedTrackID string audioTrackID string audioPipe *media.AudioPipe - // egress는 세션 수명으로 관리한다(#84). 트랙 수명(trackCtx)에 묶으면 - // 카메라 전환(트랙 교체) 순간 egress가 함께 종료되어 RTMP 연결이 끊기고 - // FFmpeg 재기동·백오프 동안 송출이 단절된다. egressCancel은 세션 종료와 - // 별개로 egress만 멈추는 경로(#83의 명시적 stream/stop)를 위해 분리해 둔다. - egress *media.RTMPEgress - egressCancel context.CancelFunc - processor *media.Processor + // egress 수명은 명시적 start~stop 구간이다(#83). 트랙 수명이 아니라(#84) + // 카메라 전환에도 살아남고, 파이프라인은 egressSlot을 통해서만 참조하므로 + // 트랙 도착 이후에 시작해도 실행 중인 파이프라인에 꽂힌다. + // baseCtx는 세션 수명 컨텍스트로, start 시점의 egress 컨텍스트 파생원이다. + baseCtx context.Context + egressSlot *media.EgressSlot + egress *media.RTMPEgress + egressCancel context.CancelFunc + streamStopReason *string + processor *media.Processor ignoredTracks int offerReceivedAt time.Time answerCreatedAt time.Time @@ -255,27 +259,17 @@ func (m *Manager) CreateForUser(userID uuid.UUID, metadata map[string]string) (* ownerHash: ownerHash, cancel: cancel, } + s.baseCtx = ctx + s.egressSlot = media.NewEgressSlot() // The audio pipe is created up front (decoupled from track arrival order) // so the egress can attach it regardless of whether the video or the audio // track's OnTrack fires first. It idles, dropping samples, until a mic track - // feeds it and the egress attaches a pipe. - if m.cfg.EnableAudioEgress && m.cfg.YoutubeStreamKey != "" { + // feeds it and the egress attaches a pipe. 사용자별 송출(#83)에서는 세션 + // 생성 시점에 송출 여부를 알 수 없으므로 오디오 egress 설정만 보고 만든다. + if m.cfg.EnableAudioEgress { s.audioPipe = media.NewAudioPipe(m.logger.With("session_id", id), m.metrics, 2) go s.audioPipe.Run(ctx) } - // egress도 트랙 도착 전에 세션 수명으로 미리 만든다(#84). Run은 첫 - // 프레임 수집(collectStartupFrames) 전에는 FFmpeg를 스폰하지 않으므로 - // 트랙 없는 세션에서는 고루틴 하나가 대기할 뿐 자원을 쓰지 않는다. - if m.cfg.YoutubeStreamKey != "" { - egressCtx, egressCancel := context.WithCancel(ctx) - s.egress = media.NewRTMPEgress(m.cfg.FFmpegPath, m.logger.With("session_id", id), m.metrics, media.TranscoderOptions{ - Gate: m.spawnGate, - WireFormat: m.cfg.AIWireFormat, - }, youtubeIngestURL+m.cfg.YoutubeStreamKey, s.audioPipe, m.cfg.EgressLatencyLog, m.cfg.EgressAudioOffset, m.cfg.EgressVideoBitrate) - s.egressCancel = egressCancel - go s.egress.Run(egressCtx) - m.logger.Info("YouTube RTMP egress enabled", "session_id", id, "url", youtubeIngestURL+"****") - } m.installHandlers(ctx, s) m.mu.Lock() m.sessions[id] = s @@ -371,6 +365,67 @@ func (m *Manager) CloseUserSessions(userID uuid.UUID) { } } +// StartStream은 세션의 처리(블러) 완료 출력에 RTMP egress를 붙인다. +// outputURL은 스트림 키가 포함된 완성 URL이므로 로그에 남기지 않는다 +// (egress가 자체 마스킹으로 기록한다). 파이프라인이 이미 돌고 있어도 +// egressSlot을 통해 즉시 프레임이 흐르기 시작한다. +func (m *Manager) StartStream(id, outputURL string) (*Session, error) { + s, err := m.Get(id) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrNotFound + } + // 트랙 먼저, 시작 나중 — 종전 501 스텁 시절부터의 계약(409)을 유지한다. + if s.rawTrackID == "" { + return nil, ErrNoVideoTrack + } + // 중지가 요청된(streamStopReason != nil) egress는 종료 절차 중이므로 + // 활성으로 보지 않는다 — stop 직후의 재시작이 이전 Run 고루틴의 종료 + // 타이밍에 좌우되면 안 된다. + if s.egress != nil && s.streamStopReason == nil && s.egress.Status().Phase != media.EgressPhaseStopped { + return nil, ErrStreamActive + } + egressCtx, egressCancel := context.WithCancel(s.baseCtx) + egress := media.NewRTMPEgress(m.cfg.FFmpegPath, m.logger.With("session_id", s.ID), m.metrics, media.TranscoderOptions{ + Gate: m.spawnGate, + WireFormat: m.cfg.AIWireFormat, + }, outputURL, s.audioPipe, m.cfg.EgressLatencyLog, m.cfg.EgressAudioOffset, m.cfg.EgressVideoBitrate) + s.egress = egress + s.egressCancel = egressCancel + s.streamStopReason = nil + s.egressSlot.Set(egress) + s.UpdatedAt = time.Now().UTC() + go egress.Run(egressCtx) + m.logger.Info("RTMP egress started", "session_id", s.ID, "url", egress.Status().TargetURL) + return s, nil +} + +// StopStream은 egress만 종료한다 — 세션과 뷰어(WebRTC) 송출은 유지된다. +// 플랫폼 쪽 방송 종료는 enableAutoStop이 담당한다(송출 중단 약 1분 후 반영, +// 실측 57.6초). +func (m *Manager) StopStream(id string) (*Session, error) { + s, err := m.Get(id) + if err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + if s.egress == nil || s.streamStopReason != nil || s.egress.Status().Phase == media.EgressPhaseStopped { + return nil, ErrStreamNotActive + } + s.egressSlot.Clear() + s.egressCancel() + reason := "user_requested" + s.streamStopReason = &reason + s.UpdatedAt = time.Now().UTC() + m.logger.Info("RTMP egress stopped", "session_id", s.ID, "reason", reason) + return s, nil +} + func (m *Manager) Delete(id, reason string) error { m.mu.Lock() s := m.sessions[id] @@ -540,9 +595,10 @@ func (m *Manager) installHandlers(ctx context.Context, s *Session) { } s.mu.Lock() s.processor = processor - // 세션 수명 egress를 재사용한다(#84): 트랙이 교체되어 이 파이프라인이 - // 새로 만들어져도 같은 egress에 Enqueue가 이어져 RTMP 연결이 유지된다. - egress := s.egress + // egress는 직접 들지 않고 세션의 슬롯을 통한다(#83): 트랙 교체로 + // 파이프라인이 재생성돼도, 방송 중 start/stop으로 egress가 갈려도 + // Enqueue 경로가 끊기지 않는다. + egressSlot := s.egressSlot s.mu.Unlock() m.logger.Info("received WebRTC video track", "session_id", s.ID, "track_id", track.ID(), "codec", track.Codec().MimeType, "mode", m.cfg.PrivacyMode) trackID := track.ID() @@ -555,7 +611,7 @@ func (m *Manager) installHandlers(ctx context.Context, s *Session) { delete(m.pipelines, s.ID) m.mu.Unlock() }() - media.RunTrack(trackCtx, m.logger.With("session_id", s.ID), track, s.Output, processor, transcoder, egress, m.metrics, m.cfg.PrivacyMode, m.cfg.FrameQueueSize) + media.RunTrack(trackCtx, m.logger.With("session_id", s.ID), track, s.Output, processor, transcoder, egressSlot, m.metrics, m.cfg.PrivacyMode, m.cfg.FrameQueueSize) s.mu.Lock() // Only clear if we are still the active track — a replacement may have // taken over while this old pipeline was draining. @@ -649,14 +705,14 @@ func (s *Session) Response() Response { response.Media.AIFallbackActive = s.processor.FallbackActive() } if s.egress != nil { - response.Stream = streamStateFromEgress(s.egress.Status(), s.rawTrackID != "") + response.Stream = streamStateFromEgress(s.egress.Status(), s.rawTrackID != "", s.streamStopReason) } return response } // streamStateFromEgress는 egress 상태 스냅샷을 API 응답 계약(StreamState)으로 -// 옮긴다. StopReason은 명시적 중지 개념이 생기는 #83에서 채운다. -func streamStateFromEgress(status media.EgressStatus, publisherActive bool) StreamState { +// 옮긴다. +func streamStateFromEgress(status media.EgressStatus, publisherActive bool, stopReason *string) StreamState { state := StreamState{ Status: string(status.Phase), StartedAt: status.StartedAt, @@ -664,6 +720,7 @@ func streamStateFromEgress(status media.EgressStatus, publisherActive bool) Stre UpdatedAt: status.UpdatedAt, PublisherActive: publisherActive, LastError: status.LastError, + StopReason: stopReason, ReconnectAttempts: status.ReconnectAttempts, } if status.TargetURL != "" { diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 55b839c..8570859 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -147,78 +147,104 @@ func TestReapsUnnegotiatedSessions(t *testing.T) { t.Fatal("unnegotiated session was not reaped within the timeout window") } -// TestCreateWithYoutubeKeyOwnsSessionScopedEgress: 전역 키가 설정되면 egress는 -// 세션 생성 시점에 세션 수명으로 만들어지고(#84 — 트랙 수명이 아니라), -// 응답 StreamState가 egress 상태를 반영하며, 세션 삭제 시 함께 종료돼야 한다. -func TestCreateWithYoutubeKeyOwnsSessionScopedEgress(t *testing.T) { - cfg := config.Config{ - PrivacyMode: config.PrivacyModeBypass, - FFmpegPath: "ffmpeg", - UDPPortMin: 42000, - UDPPortMax: 42100, - FrameQueueSize: 2, - YoutubeStreamKey: "test-stream-key", - } - manager, err := NewManager(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), metrics.New(), nil, nil) +// TestStartStreamRequiresVideoTrack: 트랙 먼저, 시작 나중 — 501 스텁 시절부터 +// 이어지는 409 계약이다(#83). +func TestStartStreamRequiresVideoTrack(t *testing.T) { + manager := newTestManager(t, 0) + created, _, err := manager.Create(nil) if err != nil { t.Fatal(err) } - t.Cleanup(manager.CloseAll) + if _, err := manager.StartStream(created.ID, "rtmps://ingest.example/live2/secret-key"); !errors.Is(err, ErrNoVideoTrack) { + t.Fatalf("StartStream error = %v, want ErrNoVideoTrack", err) + } + if _, err := manager.StartStream("missing", "rtmps://x/y"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown session error = %v, want ErrNotFound", err) + } +} +// TestStartStopStreamLifecycle: 명시적 start~stop 수명(#83) — 시작 시 슬롯에 +// 꽂히고, 응답 StreamState가 반영되며(키 비노출), 중지 시 StopReason이 남고, +// 재시작이 가능해야 한다. +func TestStartStopStreamLifecycle(t *testing.T) { + manager := newTestManager(t, 0) created, _, err := manager.Create(nil) if err != nil { t.Fatal(err) } - if created.egress == nil { - t.Fatal("session with YoutubeStreamKey must own an egress at creation") - } - if created.egressCancel == nil { - t.Fatal("session egress must have a dedicated cancel") - } + // 트랙 도착을 모사한다(OnTrack 없이 게이트만 통과시키는 최소 설정). + created.mu.Lock() + created.rawTrackID = "video-track" + created.mu.Unlock() stream := created.Response().Stream - if stream.Status != string(media.EgressPhaseIdle) { - t.Fatalf("initial Stream.Status = %q, want %q", stream.Status, media.EgressPhaseIdle) + if stream.Status != "idle" || stream.TargetURL != nil { + t.Fatalf("pre-start Stream = %+v, want default idle", stream) + } + + if _, err := manager.StartStream(created.ID, "rtmps://a.rtmps.youtube.com/live2/secret-key"); err != nil { + t.Fatal(err) } + created.mu.RLock() + slotted := created.egressSlot.Load() + active := created.egress + created.mu.RUnlock() + if active == nil || slotted != active { + t.Fatal("started egress must be installed in the session slot") + } + stream = created.Response().Stream if stream.TargetURL == nil { - t.Fatal("Stream.TargetURL must be populated when egress exists") + t.Fatal("Stream.TargetURL must be populated after start") } - if strings.Contains(*stream.TargetURL, "test-stream-key") { + if strings.Contains(*stream.TargetURL, "secret-key") { t.Fatalf("Stream.TargetURL leaks the stream key: %q", *stream.TargetURL) } if !strings.HasSuffix(*stream.TargetURL, "/****") { t.Fatalf("Stream.TargetURL is not masked: %q", *stream.TargetURL) } + if stream.StopReason != nil { + t.Fatalf("StopReason = %v before stop", *stream.StopReason) + } + + if _, err := manager.StartStream(created.ID, "rtmps://a.rtmps.youtube.com/live2/other"); !errors.Is(err, ErrStreamActive) { + t.Fatalf("double start error = %v, want ErrStreamActive", err) + } + if _, err := manager.StopStream(created.ID); err != nil { + t.Fatal(err) + } + created.mu.RLock() + cleared := created.egressSlot.Load() + created.mu.RUnlock() + if cleared != nil { + t.Fatal("stop must clear the egress slot") + } + stream = created.Response().Stream + if stream.StopReason == nil || *stream.StopReason != "user_requested" { + t.Fatalf("StopReason = %v, want user_requested", stream.StopReason) + } + if _, err := manager.StopStream(created.ID); !errors.Is(err, ErrStreamNotActive) { + t.Fatalf("double stop error = %v, want ErrStreamNotActive", err) + } + // 중지된 egress는 종료 타이밍과 무관하게 재시작을 막지 않아야 한다. + if _, err := manager.StartStream(created.ID, "rtmps://a.rtmps.youtube.com/live2/second-key"); err != nil { + t.Fatalf("restart after stop failed: %v", err) + } + + // 세션 삭제가 활성 egress를 정리해야 한다. + restarted := created + restarted.mu.RLock() + activeEgress := restarted.egress + restarted.mu.RUnlock() if err := manager.Delete(created.ID, "test"); err != nil { t.Fatal(err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if created.egress.Status().Phase == media.EgressPhaseStopped { + if activeEgress.Status().Phase == media.EgressPhaseStopped { return } time.Sleep(10 * time.Millisecond) } t.Fatal("egress did not stop after session delete") } - -// TestCreateWithoutYoutubeKeyKeepsIdleStream: 키가 없으면 egress를 만들지 않고 -// StreamState는 종전 기본값(idle)을 유지한다 — 기존 동작 보존 검증. -func TestCreateWithoutYoutubeKeyKeepsIdleStream(t *testing.T) { - manager := newTestManager(t, 0) - created, _, err := manager.Create(nil) - if err != nil { - t.Fatal(err) - } - if created.egress != nil || created.egressCancel != nil { - t.Fatal("session without YoutubeStreamKey must not own an egress") - } - stream := created.Response().Stream - if stream.Status != "idle" { - t.Fatalf("Stream.Status = %q, want %q", stream.Status, "idle") - } - if stream.TargetURL != nil { - t.Fatalf("Stream.TargetURL = %v, want nil", *stream.TargetURL) - } -} From f80998792c1e61308e4182f314c3377c5c8c6123 Mon Sep 17 00:00:00 2001 From: itzjb Date: Mon, 10 Aug 2026 17:30:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20YouTube=20=EA=B3=84=EC=A0=95=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인한 사용자에게 연결 버튼을 노출하고, 구글 팝업으로 받은 인가 코드를 서버로 넘긴다. 로그인 흐름은 그대로 두고 연결 동작만 추가했다. client_id와 스코프는 서버 조회 엔드포인트에서 받아오므로 클라이언트에 하드코딩이 없다. 인가 코드는 브라우저를 거쳐 갈 뿐 저장되지 않으며 토큰과 스트림 키는 서버에만 남는다. --- internal/server/static/client/app.js | 68 ++++++++++++++++++++++++ internal/server/static/client/index.html | 6 +++ 2 files changed, 74 insertions(+) diff --git a/internal/server/static/client/app.js b/internal/server/static/client/app.js index ed297c3..71317b8 100644 --- a/internal/server/static/client/app.js +++ b/internal/server/static/client/app.js @@ -108,6 +108,8 @@ function bindElements() { "clearLogBtn", "copyJsonBtn", "sessionJson", + "connectYoutubeBtn", + "youtubeDetail", ]) { els[id] = document.getElementById(id); } @@ -139,6 +141,7 @@ function bindEvents() { els.signUpBtn.addEventListener("click", () => void requestSignup()); els.verifyBtn.addEventListener("click", () => void verifySignup()); els.signOutBtn.addEventListener("click", () => void signOut()); + els.connectYoutubeBtn.addEventListener("click", () => void connectYoutube()); els.authPassword.addEventListener("keydown", (event) => { if (event.key === "Enter") { void signIn(); @@ -666,6 +669,11 @@ function renderAuth() { els.verifyRow.hidden = !verifying; els.verifyBtn.hidden = !verifying; els.signOutBtn.hidden = !signedIn; + // YouTube 연결은 로그인(이메일)과 별개의 부가 기능이다 — 로그인 상태에서만 노출. + els.connectYoutubeBtn.hidden = !signedIn; + if (!signedIn) { + els.youtubeDetail.hidden = true; + } els.authDetail.textContent = signedIn ? `${state.authEmail} 로 로그인됨. 세션 API를 사용할 수 있습니다.` : verifying @@ -674,6 +682,66 @@ function renderAuth() { updateButtons(); } +function setYoutubeDetail(text, isError) { + els.youtubeDetail.hidden = false; + els.youtubeDetail.textContent = text; + els.youtubeDetail.style.color = isError ? "var(--danger, #b00020)" : ""; +} + +// connectYoutube는 GIS 팝업으로 인가 코드를 받아 서버에 전달해 YouTube 계정을 +// 연결한다. 로그인 자체는 이메일 그대로이고, 이 팝업은 송출 대상 연결 전용이다. +// 코드 교환·토큰 보관은 전부 서버 몫이라 브라우저에는 인가 코드만 스친다. +async function connectYoutube() { + if (!state.accessToken) { + setYoutubeDetail("먼저 로그인하세요.", true); + return; + } + if (!window.google?.accounts?.oauth2) { + setYoutubeDetail("Google 스크립트를 아직 불러오지 못했습니다. 잠시 후 다시 시도하세요.", true); + return; + } + let config; + try { + config = await apiFetch("/auth/youtube/config"); + } catch (error) { + setYoutubeDetail(`서버에서 YouTube 연동 설정을 받지 못했습니다: ${error.message}`, true); + return; + } + setYoutubeDetail("Google 팝업에서 계정을 선택하고 동의해 주세요..."); + const codeClient = window.google.accounts.oauth2.initCodeClient({ + client_id: config.web_client_id, + scope: config.scope, + ux_mode: "popup", + callback: (response) => { + if (!response.code) { + setYoutubeDetail("Google이 인가 코드를 돌려주지 않았습니다.", true); + return; + } + void (async () => { + try { + const result = await apiFetch("/auth/youtube/connect", { + method: "POST", + body: JSON.stringify({ + server_auth_code: response.code, + code_source: "web_popup", + }), + }); + const title = result?.channel?.title || result?.channel?.id || "알 수 없는 채널"; + setYoutubeDetail(`YouTube 연결됨: ${title}`); + logEvent("ok", "YouTube account connected", result); + } catch (error) { + setYoutubeDetail(`연결 실패: ${error.message}`, true); + logEvent("error", "YouTube connect failed", { message: error.message }); + } + })(); + }, + error_callback: (error) => { + setYoutubeDetail(`Google 팝업 오류: ${error?.type || JSON.stringify(error)}`, true); + }, + }); + codeClient.requestCode(); +} + async function createSessionOnly() { await runBusy(async () => { const session = await createSession(); diff --git a/internal/server/static/client/index.html b/internal/server/static/client/index.html index 8cf054f..79f5eb5 100644 --- a/internal/server/static/client/index.html +++ b/internal/server/static/client/index.html @@ -41,6 +41,10 @@

WebRTC Client

로그인이 필요합니다. 계정이 없으면 이메일·비밀번호 입력 후 회원가입하세요. +
+ +
+
@@ -255,6 +259,8 @@

Last session JSON

+ +