diff --git a/backend/README.md b/backend/README.md index 272812a..63a7083 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,6 +49,7 @@ Compose uses the same image for `api` and the one-off `migrate` job. - `POST /api/v1/shows` — create a new episode (expects `{ ep_num, ep_title, season_name, start_time, playback_length }`) - `GET /api/v1/danime?part_id=...` — scrape dアニメ episode metadata (returns `{ ep_num, ep_title, season_name, playback_length }`) - `POST /api/v1/oauth/firebase` — verify a Firebase ID token and echo back UID/email +- `POST /api/v1/oauth/firebase/claim-admin` — set the `admin` custom claim for the UID represented by the provided ID token - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`) - `GET /healthz` — health check @@ -59,6 +60,11 @@ curl -X POST http://localhost:8082/api/v1/oauth/firebase \ -H "Content-Type: application/json" \ -d '{"id_token":""}' +# Claim Firebase admin for the current token's UID (AUTH_ENABLED=true) +curl -X POST http://localhost:8082/api/v1/oauth/firebase/claim-admin \ + -H "Content-Type: application/json" \ + -d '{"id_token":""}' + # Delete a show with auth curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \ -H "Authorization: Bearer " diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 31a6b94..43e4f6a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -43,19 +43,19 @@ func main() { // 3) wiring episodeRepo := repo.NewEpisodeRepo(pool) episodeSvc := service.NewEpisodeService(episodeRepo) - var verifier auth.TokenVerifier + var authClient auth.AuthClient if cfg.Auth.Enabled { v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase) if err != nil { log.Fatalf("firebase auth init failed: %v", err) } - verifier = v + authClient = v log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID) } else { log.Printf("auth disabled (AUTH_ENABLED=false)") } - router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled) + router := httpapi.NewRouter(episodeSvc, authClient, cfg.Auth.Enabled) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // 4) HTTP server with timeouts diff --git a/backend/docs/docs.go b/backend/docs/docs.go index df099b4..4fddb42 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -236,6 +236,64 @@ const docTemplate = `{ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase ID token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimRes" + } + }, + "400": { + "description": "invalid payload", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "401": { + "description": "invalid token", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "claim failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "503": { + "description": "auth disabled", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + } + }, "/api/v1/shows": { "get": { "description": "List all rows from ` + "`" + `current` + "`" + ` table.", @@ -444,6 +502,39 @@ const docTemplate = `{ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "id_token" + ], + "properties": { + "id_token": { + "type": "string", + "example": "\u003cfirebase-id-token\u003e" + } + } + }, + "httpapi.FirebaseAdminClaimRes": { + "type": "object", + "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "uid": { + "type": "string", + "example": "abc123" + } + } + }, "httpapi.FirebaseOAuthReq": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 3cf335d..a16d9f1 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -234,6 +234,64 @@ } } }, + "/api/v1/oauth/firebase/claim-admin": { + "post": { + "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Claim Firebase admin", + "parameters": [ + { + "description": "Firebase ID token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimReq" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.FirebaseAdminClaimRes" + } + }, + "400": { + "description": "invalid payload", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "401": { + "description": "invalid token", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "500": { + "description": "claim failed", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + }, + "503": { + "description": "auth disabled", + "schema": { + "$ref": "#/definitions/httpapi.HTTPError" + } + } + } + } + }, "/api/v1/shows": { "get": { "description": "List all rows from `current` table.", @@ -442,6 +500,39 @@ } } }, + "httpapi.FirebaseAdminClaimReq": { + "type": "object", + "required": [ + "id_token" + ], + "properties": { + "id_token": { + "type": "string", + "example": "\u003cfirebase-id-token\u003e" + } + } + }, + "httpapi.FirebaseAdminClaimRes": { + "type": "object", + "properties": { + "admin": { + "type": "boolean", + "example": true + }, + "custom_claims": { + "type": "object", + "additionalProperties": true + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "uid": { + "type": "string", + "example": "abc123" + } + } + }, "httpapi.FirebaseOAuthReq": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 4d0508d..c7abd10 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -66,6 +66,29 @@ definitions: example: フルーツバスケット 2nd season type: string type: object + httpapi.FirebaseAdminClaimReq: + properties: + id_token: + example: + type: string + required: + - id_token + type: object + httpapi.FirebaseAdminClaimRes: + properties: + admin: + example: true + type: boolean + custom_claims: + additionalProperties: true + type: object + email: + example: user@example.com + type: string + uid: + example: abc123 + type: string + type: object httpapi.FirebaseOAuthReq: properties: id_token: @@ -290,6 +313,45 @@ paths: summary: Verify Firebase ID token tags: - auth + /api/v1/oauth/firebase/claim-admin: + post: + consumes: + - application/json + description: Set the \"admin\" custom claim for the user represented by the + ID token. + parameters: + - description: Firebase ID token + in: body + name: body + required: true + schema: + $ref: '#/definitions/httpapi.FirebaseAdminClaimReq' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.FirebaseAdminClaimRes' + "400": + description: invalid payload + schema: + $ref: '#/definitions/httpapi.HTTPError' + "401": + description: invalid token + schema: + $ref: '#/definitions/httpapi.HTTPError' + "500": + description: claim failed + schema: + $ref: '#/definitions/httpapi.HTTPError' + "503": + description: auth disabled + schema: + $ref: '#/definitions/httpapi.HTTPError' + summary: Claim Firebase admin + tags: + - auth /api/v1/shows: delete: description: Delete a row from `current` by ID. diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go index dac2275..e272bc0 100644 --- a/backend/internal/auth/firebase.go +++ b/backend/internal/auth/firebase.go @@ -16,6 +16,12 @@ type TokenVerifier interface { Verify(ctx context.Context, token string) (*fbauth.Token, error) } +// AuthClient can verify tokens and mutate Firebase custom claims. +type AuthClient interface { + TokenVerifier + SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) +} + // FirebaseAuth verifies Firebase ID tokens. type FirebaseAuth struct { client *fbauth.Client @@ -46,3 +52,20 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) { return f.client.VerifyIDToken(ctx, token) } + +// SetAdminClaim sets the "admin" custom claim while preserving existing claims. +func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) { + user, err := f.client.GetUser(ctx, uid) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + claims := make(map[string]interface{}, len(user.CustomClaims)+1) + for k, v := range user.CustomClaims { + claims[k] = v + } + claims["admin"] = true + if err := f.client.SetCustomUserClaims(ctx, uid, claims); err != nil { + return nil, fmt.Errorf("set custom claims: %w", err) + } + return claims, nil +} diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 44ba1fe..6b84a06 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -15,7 +15,7 @@ import ( "github.com/gin-gonic/gin" ) -func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine { +func NewRouter(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine { r := gin.New() r.Use(gin.Logger(), gin.Recovery()) @@ -29,12 +29,13 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo v1.POST("/current", setCurrentHandler(svc)) v1.POST("/shows", createShowHandler(svc)) v1.GET("/shows", listShowsHandler(svc)) - v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled)) + v1.POST("/oauth/firebase", oauthFirebaseHandler(authClient, authEnabled)) + v1.POST("/oauth/firebase/claim-admin", claimFirebaseAdminHandler(authClient, authEnabled)) v1.GET("/danime", getDanimeHandler()) - if authEnabled && verifier != nil { + if authEnabled && authClient != nil { protected := v1.Group("/") - protected.Use(AuthMiddleware(verifier)) + protected.Use(AuthMiddleware(authClient)) protected.DELETE("/shows", deleteShowsHandler(svc)) protected.POST("/archive", moveToArchiveHandler(svc)) } else { @@ -341,3 +342,53 @@ func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.Han }) } } + +// claimFirebaseAdminHandler godoc +// @Summary Claim Firebase admin +// @Description Set the \"admin\" custom claim for the user represented by the ID token. +// @Tags auth +// @Accept json +// @Produce json +// @Param body body FirebaseAdminClaimReq true "Firebase ID token" +// @Success 200 {object} FirebaseAdminClaimRes +// @Failure 400 {object} HTTPError "invalid payload" +// @Failure 401 {object} HTTPError "invalid token" +// @Failure 503 {object} HTTPError "auth disabled" +// @Failure 500 {object} HTTPError "claim failed" +// @Router /api/v1/oauth/firebase/claim-admin [post] +func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.HandlerFunc { + return func(c *gin.Context) { + if !authEnabled || client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth disabled"}) + return + } + var req FirebaseAdminClaimReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + idToken := strings.TrimSpace(req.IDToken) + if idToken == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"}) + return + } + token, err := client.Verify(c.Request.Context(), idToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + claims, err := client.SetAdminClaim(c.Request.Context(), token.UID) + if err != nil { + log.Printf("set admin claim failed for uid=%s: %v", token.UID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) + return + } + email, _ := token.Claims["email"].(string) + c.JSON(http.StatusOK, FirebaseAdminClaimRes{ + UID: token.UID, + Email: email, + Admin: true, + CustomClaims: claims, + }) + } +} diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 5f277bc..13b3967 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -10,6 +10,9 @@ import ( "testing" "time" + fbauth "firebase.google.com/go/v4/auth" + + "watch-party-backend/internal/auth" "watch-party-backend/internal/core/episode" "watch-party-backend/internal/danime" httpapi "watch-party-backend/internal/http" @@ -67,8 +70,12 @@ func (f *fakeSvc) Create(ctx context.Context, in episode.NewShowInput) (episode. // ---- helpers ---- func newRouterWithSvc(svc episode.UseCases) *gin.Engine { + return newRouterWithOpts(svc, nil, false) +} + +func newRouterWithOpts(svc episode.UseCases, authClient auth.AuthClient, authEnabled bool) *gin.Engine { gin.SetMode(gin.TestMode) - return httpapi.NewRouter(svc, nil, false) + return httpapi.NewRouter(svc, authClient, authEnabled) } func doJSON(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { @@ -86,6 +93,29 @@ func doJSON(t *testing.T, r *gin.Engine, method, path string, body any) *httptes return w } +type fakeAuthClient struct { + token *fbauth.Token + verifyErr error + claimErr error + claims map[string]interface{} + lastUID string +} + +func (f *fakeAuthClient) Verify(_ context.Context, _ string) (*fbauth.Token, error) { + return f.token, f.verifyErr +} + +func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (map[string]interface{}, error) { + f.lastUID = uid + if f.claimErr != nil { + return nil, f.claimErr + } + if f.claims != nil { + return f.claims, nil + } + return map[string]interface{}{"admin": true}, nil +} + // ---- tests ---- func TestHealthz_OK(t *testing.T) { @@ -399,6 +429,79 @@ func TestListShows_OK(t *testing.T) { } } +func TestClaimFirebaseAdmin_Disabled(t *testing.T) { + r := newRouterWithSvc(&fakeSvc{}) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { + client := &fakeAuthClient{token: &fbauth.Token{UID: "uid-1"}} + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_InvalidToken(t *testing.T) { + client := &fakeAuthClient{verifyErr: errors.New("bad token")} + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { + client := &fakeAuthClient{ + token: &fbauth.Token{UID: "uid-2"}, + claimErr: errors.New("cannot set"), + } + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + if client.lastUID != "uid-2" { + t.Fatalf("expected claim to run on uid-2, got %s", client.lastUID) + } +} + +func TestClaimFirebaseAdmin_OK(t *testing.T) { + client := &fakeAuthClient{ + token: &fbauth.Token{ + UID: "uid-3", + Claims: map[string]interface{}{"email": "user@example.com"}, + }, + claims: map[string]interface{}{"admin": true, "role": "owner"}, + } + r := newRouterWithOpts(&fakeSvc{}, client, true) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"id_token": "tok"}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("json: %v", err) + } + if body["uid"] != "uid-3" || body["admin"] != true { + t.Fatalf("unexpected body: %+v", body) + } + claims, ok := body["custom_claims"].(map[string]any) + if !ok || claims["role"] != "owner" { + t.Fatalf("claims not returned as expected: %+v", body["custom_claims"]) + } + if client.lastUID != "uid-3" { + t.Fatalf("expected claim to run on uid-3, got %s", client.lastUID) + } + if body["email"] != "user@example.com" { + t.Fatalf("expected email propagated, got %v", body["email"]) + } +} + func TestListShows_Error(t *testing.T) { svc := &fakeSvc{listErr: errors.New("query failed")} r := newRouterWithSvc(svc) diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 35fa5f8..76c8782 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -70,3 +70,16 @@ type FirebaseOAuthRes struct { Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/"` Expires int64 `json:"expires,omitempty" example:"1700000000"` } + +// FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. +type FirebaseAdminClaimReq struct { + IDToken string `json:"id_token" binding:"required" example:""` +} + +// FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin. +type FirebaseAdminClaimRes struct { + UID string `json:"uid" example:"abc123"` + Email string `json:"email,omitempty" example:"user@example.com"` + Admin bool `json:"admin" example:"true"` + CustomClaims map[string]interface{} `json:"custom_claims,omitempty"` +}