Compare commits

..

No commits in common. "e3fe20005b2275427ac2a11874d99c233786a719" and "2063dffc09ad3747f87196435b79a367965ff900" have entirely different histories.

8 changed files with 16 additions and 114 deletions

View File

@ -15,7 +15,6 @@ POSTGRES_USER=admin
POSTGRES_PASSWORD=admin POSTGRES_PASSWORD=admin
TZ=Asia/Tokyo TZ=Asia/Tokyo
COMPOSE_PLATFORM=linux/arm64/v8 COMPOSE_PLATFORM=linux/arm64/v8
ROOT_ADMIN_UID=1
# App runtime (inside Docker network) # App runtime (inside Docker network)
PGHOST=db PGHOST=db

View File

@ -43,19 +43,19 @@ func main() {
// 3) wiring // 3) wiring
episodeRepo := repo.NewEpisodeRepo(pool) episodeRepo := repo.NewEpisodeRepo(pool)
episodeSvc := service.NewEpisodeService(episodeRepo) episodeSvc := service.NewEpisodeService(episodeRepo)
var authMgr auth.AdminManager var verifier auth.TokenVerifier
if cfg.Auth.Enabled { if cfg.Auth.Enabled {
v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase) v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase)
if err != nil { if err != nil {
log.Fatalf("firebase auth init failed: %v", err) log.Fatalf("firebase auth init failed: %v", err)
} }
authMgr = v verifier = v
log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID) log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID)
} else { } else {
log.Printf("auth disabled (AUTH_ENABLED=false)") log.Printf("auth disabled (AUTH_ENABLED=false)")
} }
router := httpapi.NewRouter(episodeSvc, authMgr, cfg.Auth.Enabled, cfg.Auth.RootUID) router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled)
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
// 4) HTTP server with timeouts // 4) HTTP server with timeouts

View File

@ -16,12 +16,6 @@ type TokenVerifier interface {
Verify(ctx context.Context, token string) (*fbauth.Token, error) Verify(ctx context.Context, token string) (*fbauth.Token, error)
} }
// AdminManager can set admin claims.
type AdminManager interface {
TokenVerifier
SetAdmin(ctx context.Context, uid string, admin bool) error
}
// FirebaseAuth verifies Firebase ID tokens. // FirebaseAuth verifies Firebase ID tokens.
type FirebaseAuth struct { type FirebaseAuth struct {
client *fbauth.Client client *fbauth.Client
@ -52,10 +46,3 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA
func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) { func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) {
return f.client.VerifyIDToken(ctx, token) return f.client.VerifyIDToken(ctx, token)
} }
// SetAdmin sets or clears the custom claim "admin" for a given uid.
func (f *FirebaseAuth) SetAdmin(ctx context.Context, uid string, admin bool) error {
return f.client.SetCustomUserClaims(ctx, uid, map[string]interface{}{
"admin": admin,
})
}

View File

@ -23,7 +23,6 @@ type Config struct {
type AuthConfig struct { type AuthConfig struct {
Enabled bool Enabled bool
RootUID string
} }
type FirebaseConfig struct { type FirebaseConfig struct {
@ -51,7 +50,6 @@ func Load() Config {
GinMode: getenv("GIN_MODE", "release"), GinMode: getenv("GIN_MODE", "release"),
Auth: AuthConfig{ Auth: AuthConfig{
Enabled: getenvBool("AUTH_ENABLED", false), Enabled: getenvBool("AUTH_ENABLED", false),
RootUID: getenv("ROOT_ADMIN_UID", ""),
}, },
Firebase: FirebaseConfig{ Firebase: FirebaseConfig{
ProjectID: getenv("FIREBASE_PROJECT_ID", ""), ProjectID: getenv("FIREBASE_PROJECT_ID", ""),

View File

@ -15,7 +15,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool, rootUID string) *gin.Engine { func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery()) r.Use(gin.Logger(), gin.Recovery())
@ -32,17 +32,11 @@ func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool
v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled)) v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
v1.GET("/danime", getDanimeHandler()) v1.GET("/danime", getDanimeHandler())
if authEnabled && authMgr != nil { if authEnabled && verifier != nil {
protected := v1.Group("/") protected := v1.Group("/")
protected.Use(AuthMiddleware(authMgr), RequireAdmin()) protected.Use(AuthMiddleware(verifier))
protected.DELETE("/shows", deleteShowsHandler(svc)) protected.DELETE("/shows", deleteShowsHandler(svc))
protected.POST("/archive", moveToArchiveHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc))
if rootUID != "" {
admin := v1.Group("/admin")
admin.Use(AuthMiddleware(authMgr), RequireRootUID(rootUID))
admin.POST("/grant", setAdminHandler(authMgr))
}
} else { } else {
v1.DELETE("/shows", deleteShowsHandler(svc)) v1.DELETE("/shows", deleteShowsHandler(svc))
v1.POST("/archive", moveToArchiveHandler(svc)) v1.POST("/archive", moveToArchiveHandler(svc))
@ -51,38 +45,6 @@ func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool
return r return r
} }
// setAdminHandler godoc
// @Summary Grant/revoke admin
// @Description Set the custom claim "admin" for a user. Requires root admin UID.
// @Tags admin
// @Accept json
// @Produce json
// @Param body body SetAdminReq true "UID and desired admin flag"
// @Success 204 {string} string "no content"
// @Failure 400 {object} HTTPError "invalid payload"
// @Failure 403 {object} HTTPError "forbidden"
// @Failure 500 {object} HTTPError "failed to set claim"
// @Router /api/v1/admin/grant [post]
func setAdminHandler(authMgr auth.AdminManager) gin.HandlerFunc {
return func(c *gin.Context) {
var req SetAdminReq
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.UID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
admin := true
if req.Admin != nil {
admin = *req.Admin
}
if err := authMgr.SetAdmin(c.Request.Context(), req.UID, admin); err != nil {
log.Printf("set admin failed for uid=%s: %v", req.UID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set claim"})
return
}
c.Status(http.StatusNoContent)
}
}
// GET /healthz // GET /healthz
func healthzHandler(c *gin.Context) { func healthzHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})

View File

@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"strings" "strings"
fbauth "firebase.google.com/go/v4/auth"
"watch-party-backend/internal/auth" "watch-party-backend/internal/auth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -32,45 +31,3 @@ func AuthMiddleware(verifier auth.TokenVerifier) gin.HandlerFunc {
c.Next() c.Next()
} }
} }
// RequireAdmin enforces a custom claim "admin": true on the Firebase token.
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
val, ok := c.Get("firebaseToken")
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
token, ok := val.(*fbauth.Token)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if isAdmin, ok := token.Claims["admin"].(bool); !ok || !isAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}
// RequireRootUID enforces that the caller's UID matches the configured root admin UID.
func RequireRootUID(rootUID string) gin.HandlerFunc {
return func(c *gin.Context) {
val, ok := c.Get("firebaseToken")
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
token, ok := val.(*fbauth.Token)
if !ok || token.UID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
if token.UID != rootUID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}

View File

@ -70,9 +70,3 @@ type FirebaseOAuthRes struct {
Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"` Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"`
Expires int64 `json:"expires,omitempty" example:"1700000000"` Expires int64 `json:"expires,omitempty" example:"1700000000"`
} }
// SetAdminReq is the request body for POST /api/v1/admin/grant.
type SetAdminReq struct {
UID string `json:"uid" binding:"required" example:"abc123"`
Admin *bool `json:"admin,omitempty" example:"true"`
}

View File

@ -78,12 +78,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
try { try {
await signInWithPopup(auth, provider); await signInWithPopup(auth, provider);
} catch (err: any) { } catch (err: any) {
// Avoid redirect fallback to prevent navigation/404; surface a clearer popup-blocked message.
const code = err?.code ?? ""; const code = err?.code ?? "";
if (code === "auth/popup-blocked") { // Common cases where redirect works better
setError("ポップアップがブロックされました。ブラウザで許可してください。"); const redirectable = [
console.error("signin popup blocked:", err); "auth/popup-blocked",
return; "auth/operation-not-supported-in-this-environment",
"auth/unauthorized-domain",
];
if (redirectable.includes(code)) {
const { signInWithRedirect } = await import("firebase/auth");
await signInWithRedirect(auth, provider);
return; // navigation will happen
} }
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
setError(msg); // keep the message for the UI setError(msg); // keep the message for the UI