diff --git a/backend/README.md b/backend/README.md index 63a7083..df1da95 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,7 +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 +- `POST /api/v1/oauth/firebase/claim-admin` — set the `admin` custom claim for a given Firebase UID - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`) - `GET /healthz` — health check @@ -60,10 +60,10 @@ 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) +# Claim Firebase admin for a specific UID (AUTH_ENABLED=true) curl -X POST http://localhost:8082/api/v1/oauth/firebase/claim-admin \ -H "Content-Type: application/json" \ - -d '{"id_token":""}' + -d '{"uid":""}' # Delete a show with auth curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \ diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 4fddb42..0c47286 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -238,7 +238,7 @@ const docTemplate = `{ }, "/api/v1/oauth/firebase/claim-admin": { "post": { - "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", "consumes": [ "application/json" ], @@ -251,7 +251,7 @@ const docTemplate = `{ "summary": "Claim Firebase admin", "parameters": [ { - "description": "Firebase ID token", + "description": "Firebase UID", "name": "body", "in": "body", "required": true, @@ -273,12 +273,6 @@ const docTemplate = `{ "$ref": "#/definitions/httpapi.HTTPError" } }, - "401": { - "description": "invalid token", - "schema": { - "$ref": "#/definitions/httpapi.HTTPError" - } - }, "500": { "description": "claim failed", "schema": { @@ -505,12 +499,12 @@ const docTemplate = `{ "httpapi.FirebaseAdminClaimReq": { "type": "object", "required": [ - "id_token" + "uid" ], "properties": { - "id_token": { + "uid": { "type": "string", - "example": "\u003cfirebase-id-token\u003e" + "example": "abc123" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a16d9f1..6ebab89 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -236,7 +236,7 @@ }, "/api/v1/oauth/firebase/claim-admin": { "post": { - "description": "Set the \\\"admin\\\" custom claim for the user represented by the ID token.", + "description": "Set the \\\"admin\\\" custom claim for the given Firebase UID.", "consumes": [ "application/json" ], @@ -249,7 +249,7 @@ "summary": "Claim Firebase admin", "parameters": [ { - "description": "Firebase ID token", + "description": "Firebase UID", "name": "body", "in": "body", "required": true, @@ -271,12 +271,6 @@ "$ref": "#/definitions/httpapi.HTTPError" } }, - "401": { - "description": "invalid token", - "schema": { - "$ref": "#/definitions/httpapi.HTTPError" - } - }, "500": { "description": "claim failed", "schema": { @@ -503,12 +497,12 @@ "httpapi.FirebaseAdminClaimReq": { "type": "object", "required": [ - "id_token" + "uid" ], "properties": { - "id_token": { + "uid": { "type": "string", - "example": "\u003cfirebase-id-token\u003e" + "example": "abc123" } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index c7abd10..5edd67a 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -68,11 +68,11 @@ definitions: type: object httpapi.FirebaseAdminClaimReq: properties: - id_token: - example: + uid: + example: abc123 type: string required: - - id_token + - uid type: object httpapi.FirebaseAdminClaimRes: properties: @@ -317,10 +317,9 @@ paths: post: consumes: - application/json - description: Set the \"admin\" custom claim for the user represented by the - ID token. + description: Set the \"admin\" custom claim for the given Firebase UID. parameters: - - description: Firebase ID token + - description: Firebase UID in: body name: body required: true @@ -337,10 +336,6 @@ paths: description: invalid payload schema: $ref: '#/definitions/httpapi.HTTPError' - "401": - description: invalid token - schema: - $ref: '#/definitions/httpapi.HTTPError' "500": description: claim failed schema: diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go index e272bc0..22a0a87 100644 --- a/backend/internal/auth/firebase.go +++ b/backend/internal/auth/firebase.go @@ -19,7 +19,7 @@ type TokenVerifier interface { // AuthClient can verify tokens and mutate Firebase custom claims. type AuthClient interface { TokenVerifier - SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) + SetAdminClaim(ctx context.Context, uid string) (string, map[string]interface{}, error) } // FirebaseAuth verifies Firebase ID tokens. @@ -54,10 +54,10 @@ func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, } // SetAdminClaim sets the "admin" custom claim while preserving existing claims. -func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[string]interface{}, error) { +func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (string, map[string]interface{}, error) { user, err := f.client.GetUser(ctx, uid) if err != nil { - return nil, fmt.Errorf("get user: %w", err) + return "", nil, fmt.Errorf("get user: %w", err) } claims := make(map[string]interface{}, len(user.CustomClaims)+1) for k, v := range user.CustomClaims { @@ -65,7 +65,7 @@ func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[strin } claims["admin"] = true if err := f.client.SetCustomUserClaims(ctx, uid, claims); err != nil { - return nil, fmt.Errorf("set custom claims: %w", err) + return "", nil, fmt.Errorf("set custom claims: %w", err) } - return claims, nil + return user.Email, claims, nil } diff --git a/backend/internal/http/handlers.go b/backend/internal/http/handlers.go index 6b84a06..fff0137 100644 --- a/backend/internal/http/handlers.go +++ b/backend/internal/http/handlers.go @@ -345,14 +345,13 @@ 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. +// @Description Set the \"admin\" custom claim for the given Firebase UID. // @Tags auth // @Accept json // @Produce json -// @Param body body FirebaseAdminClaimReq true "Firebase ID token" +// @Param body body FirebaseAdminClaimReq true "Firebase UID" // @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] @@ -367,25 +366,19 @@ func claimFirebaseAdminHandler(client auth.AuthClient, authEnabled bool) gin.Han 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"}) + uid := strings.TrimSpace(req.UID) + if uid == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"}) return } - token, err := client.Verify(c.Request.Context(), idToken) + email, claims, err := client.SetAdminClaim(c.Request.Context(), uid) 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) + log.Printf("set admin claim failed for uid=%s: %v", 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, + UID: uid, Email: email, Admin: true, CustomClaims: claims, diff --git a/backend/internal/http/handlers_test.go b/backend/internal/http/handlers_test.go index 13b3967..dbec791 100644 --- a/backend/internal/http/handlers_test.go +++ b/backend/internal/http/handlers_test.go @@ -98,6 +98,7 @@ type fakeAuthClient struct { verifyErr error claimErr error claims map[string]interface{} + email string lastUID string } @@ -105,15 +106,15 @@ func (f *fakeAuthClient) Verify(_ context.Context, _ string) (*fbauth.Token, err return f.token, f.verifyErr } -func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (map[string]interface{}, error) { +func (f *fakeAuthClient) SetAdminClaim(_ context.Context, uid string) (string, map[string]interface{}, error) { f.lastUID = uid if f.claimErr != nil { - return nil, f.claimErr + return "", nil, f.claimErr } if f.claims != nil { - return f.claims, nil + return f.email, f.claims, nil } - return map[string]interface{}{"admin": true}, nil + return f.email, map[string]interface{}{"admin": true}, nil } // ---- tests ---- @@ -431,14 +432,14 @@ 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"}) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-x"}) 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"}} + client := &fakeAuthClient{} r := newRouterWithOpts(&fakeSvc{}, client, true) w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", nil) if w.Code != http.StatusBadRequest { @@ -446,22 +447,12 @@ func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { } } -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"}) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-2"}) if w.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", w.Code) } @@ -472,14 +463,11 @@ func TestClaimFirebaseAdmin_ClaimError(t *testing.T) { func TestClaimFirebaseAdmin_OK(t *testing.T) { client := &fakeAuthClient{ - token: &fbauth.Token{ - UID: "uid-3", - Claims: map[string]interface{}{"email": "user@example.com"}, - }, + 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"}) + w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", map[string]any{"uid": "uid-3"}) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } diff --git a/backend/internal/http/types.go b/backend/internal/http/types.go index 76c8782..c90a944 100644 --- a/backend/internal/http/types.go +++ b/backend/internal/http/types.go @@ -73,7 +73,7 @@ type FirebaseOAuthRes struct { // FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. type FirebaseAdminClaimReq struct { - IDToken string `json:"id_token" binding:"required" example:""` + UID string `json:"uid" binding:"required" example:"abc123"` } // FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin.