feat(auth): update claim-admin endpoint to use Firebase UID instead of ID token

This commit is contained in:
Nik Afiq 2025-12-17 22:48:21 +09:00
parent 4b5a8c1d46
commit 204c71d7b5
8 changed files with 42 additions and 78 deletions

View File

@ -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 }`) - `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 }`) - `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` — 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`) - `DELETE /api/v1/shows?id=...` — delete a show (guarded by Firebase auth when `AUTH_ENABLED=true`)
- `GET /healthz` — health check - `GET /healthz` — health check
@ -60,10 +60,10 @@ curl -X POST http://localhost:8082/api/v1/oauth/firebase \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"id_token":"<firebase-id-token>"}' -d '{"id_token":"<firebase-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 \ curl -X POST http://localhost:8082/api/v1/oauth/firebase/claim-admin \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"id_token":"<firebase-id-token>"}' -d '{"uid":"<firebase-uid>"}'
# Delete a show with auth # Delete a show with auth
curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \ curl -X DELETE "http://localhost:8082/api/v1/shows?id=123" \

View File

@ -238,7 +238,7 @@ const docTemplate = `{
}, },
"/api/v1/oauth/firebase/claim-admin": { "/api/v1/oauth/firebase/claim-admin": {
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -251,7 +251,7 @@ const docTemplate = `{
"summary": "Claim Firebase admin", "summary": "Claim Firebase admin",
"parameters": [ "parameters": [
{ {
"description": "Firebase ID token", "description": "Firebase UID",
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true, "required": true,
@ -273,12 +273,6 @@ const docTemplate = `{
"$ref": "#/definitions/httpapi.HTTPError" "$ref": "#/definitions/httpapi.HTTPError"
} }
}, },
"401": {
"description": "invalid token",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
},
"500": { "500": {
"description": "claim failed", "description": "claim failed",
"schema": { "schema": {
@ -505,12 +499,12 @@ const docTemplate = `{
"httpapi.FirebaseAdminClaimReq": { "httpapi.FirebaseAdminClaimReq": {
"type": "object", "type": "object",
"required": [ "required": [
"id_token" "uid"
], ],
"properties": { "properties": {
"id_token": { "uid": {
"type": "string", "type": "string",
"example": "\u003cfirebase-id-token\u003e" "example": "abc123"
} }
} }
}, },

View File

@ -236,7 +236,7 @@
}, },
"/api/v1/oauth/firebase/claim-admin": { "/api/v1/oauth/firebase/claim-admin": {
"post": { "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": [ "consumes": [
"application/json" "application/json"
], ],
@ -249,7 +249,7 @@
"summary": "Claim Firebase admin", "summary": "Claim Firebase admin",
"parameters": [ "parameters": [
{ {
"description": "Firebase ID token", "description": "Firebase UID",
"name": "body", "name": "body",
"in": "body", "in": "body",
"required": true, "required": true,
@ -271,12 +271,6 @@
"$ref": "#/definitions/httpapi.HTTPError" "$ref": "#/definitions/httpapi.HTTPError"
} }
}, },
"401": {
"description": "invalid token",
"schema": {
"$ref": "#/definitions/httpapi.HTTPError"
}
},
"500": { "500": {
"description": "claim failed", "description": "claim failed",
"schema": { "schema": {
@ -503,12 +497,12 @@
"httpapi.FirebaseAdminClaimReq": { "httpapi.FirebaseAdminClaimReq": {
"type": "object", "type": "object",
"required": [ "required": [
"id_token" "uid"
], ],
"properties": { "properties": {
"id_token": { "uid": {
"type": "string", "type": "string",
"example": "\u003cfirebase-id-token\u003e" "example": "abc123"
} }
} }
}, },

View File

@ -68,11 +68,11 @@ definitions:
type: object type: object
httpapi.FirebaseAdminClaimReq: httpapi.FirebaseAdminClaimReq:
properties: properties:
id_token: uid:
example: <firebase-id-token> example: abc123
type: string type: string
required: required:
- id_token - uid
type: object type: object
httpapi.FirebaseAdminClaimRes: httpapi.FirebaseAdminClaimRes:
properties: properties:
@ -317,10 +317,9 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: Set the \"admin\" custom claim for the user represented by the description: Set the \"admin\" custom claim for the given Firebase UID.
ID token.
parameters: parameters:
- description: Firebase ID token - description: Firebase UID
in: body in: body
name: body name: body
required: true required: true
@ -337,10 +336,6 @@ paths:
description: invalid payload description: invalid payload
schema: schema:
$ref: '#/definitions/httpapi.HTTPError' $ref: '#/definitions/httpapi.HTTPError'
"401":
description: invalid token
schema:
$ref: '#/definitions/httpapi.HTTPError'
"500": "500":
description: claim failed description: claim failed
schema: schema:

View File

@ -19,7 +19,7 @@ type TokenVerifier interface {
// AuthClient can verify tokens and mutate Firebase custom claims. // AuthClient can verify tokens and mutate Firebase custom claims.
type AuthClient interface { type AuthClient interface {
TokenVerifier 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. // 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. // 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) user, err := f.client.GetUser(ctx, uid)
if err != nil { 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) claims := make(map[string]interface{}, len(user.CustomClaims)+1)
for k, v := range user.CustomClaims { for k, v := range user.CustomClaims {
@ -65,7 +65,7 @@ func (f *FirebaseAuth) SetAdminClaim(ctx context.Context, uid string) (map[strin
} }
claims["admin"] = true claims["admin"] = true
if err := f.client.SetCustomUserClaims(ctx, uid, claims); err != nil { 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
} }

View File

@ -345,14 +345,13 @@ func oauthFirebaseHandler(verifier auth.TokenVerifier, authEnabled bool) gin.Han
// claimFirebaseAdminHandler godoc // claimFirebaseAdminHandler godoc
// @Summary Claim Firebase admin // @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 // @Tags auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param body body FirebaseAdminClaimReq true "Firebase ID token" // @Param body body FirebaseAdminClaimReq true "Firebase UID"
// @Success 200 {object} FirebaseAdminClaimRes // @Success 200 {object} FirebaseAdminClaimRes
// @Failure 400 {object} HTTPError "invalid payload" // @Failure 400 {object} HTTPError "invalid payload"
// @Failure 401 {object} HTTPError "invalid token"
// @Failure 503 {object} HTTPError "auth disabled" // @Failure 503 {object} HTTPError "auth disabled"
// @Failure 500 {object} HTTPError "claim failed" // @Failure 500 {object} HTTPError "claim failed"
// @Router /api/v1/oauth/firebase/claim-admin [post] // @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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return return
} }
idToken := strings.TrimSpace(req.IDToken) uid := strings.TrimSpace(req.UID)
if idToken == "" { if uid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id_token is required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "uid is required"})
return return
} }
token, err := client.Verify(c.Request.Context(), idToken) email, claims, err := client.SetAdminClaim(c.Request.Context(), uid)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) log.Printf("set admin claim failed for uid=%s: %v", uid, err)
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"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
return return
} }
email, _ := token.Claims["email"].(string)
c.JSON(http.StatusOK, FirebaseAdminClaimRes{ c.JSON(http.StatusOK, FirebaseAdminClaimRes{
UID: token.UID, UID: uid,
Email: email, Email: email,
Admin: true, Admin: true,
CustomClaims: claims, CustomClaims: claims,

View File

@ -98,6 +98,7 @@ type fakeAuthClient struct {
verifyErr error verifyErr error
claimErr error claimErr error
claims map[string]interface{} claims map[string]interface{}
email string
lastUID string lastUID string
} }
@ -105,15 +106,15 @@ func (f *fakeAuthClient) Verify(_ context.Context, _ string) (*fbauth.Token, err
return f.token, f.verifyErr 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 f.lastUID = uid
if f.claimErr != nil { if f.claimErr != nil {
return nil, f.claimErr return "", nil, f.claimErr
} }
if f.claims != nil { 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 ---- // ---- tests ----
@ -431,14 +432,14 @@ func TestListShows_OK(t *testing.T) {
func TestClaimFirebaseAdmin_Disabled(t *testing.T) { func TestClaimFirebaseAdmin_Disabled(t *testing.T) {
r := newRouterWithSvc(&fakeSvc{}) 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 { if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code) t.Fatalf("expected 503, got %d", w.Code)
} }
} }
func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) { func TestClaimFirebaseAdmin_InvalidPayload(t *testing.T) {
client := &fakeAuthClient{token: &fbauth.Token{UID: "uid-1"}} client := &fakeAuthClient{}
r := newRouterWithOpts(&fakeSvc{}, client, true) r := newRouterWithOpts(&fakeSvc{}, client, true)
w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", nil) w := doJSON(t, r, http.MethodPost, "/api/v1/oauth/firebase/claim-admin", nil)
if w.Code != http.StatusBadRequest { 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) { func TestClaimFirebaseAdmin_ClaimError(t *testing.T) {
client := &fakeAuthClient{ client := &fakeAuthClient{
token: &fbauth.Token{UID: "uid-2"},
claimErr: errors.New("cannot set"), claimErr: errors.New("cannot set"),
} }
r := newRouterWithOpts(&fakeSvc{}, client, true) 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 { if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code) t.Fatalf("expected 500, got %d", w.Code)
} }
@ -472,14 +463,11 @@ func TestClaimFirebaseAdmin_ClaimError(t *testing.T) {
func TestClaimFirebaseAdmin_OK(t *testing.T) { func TestClaimFirebaseAdmin_OK(t *testing.T) {
client := &fakeAuthClient{ client := &fakeAuthClient{
token: &fbauth.Token{ email: "user@example.com",
UID: "uid-3",
Claims: map[string]interface{}{"email": "user@example.com"},
},
claims: map[string]interface{}{"admin": true, "role": "owner"}, claims: map[string]interface{}{"admin": true, "role": "owner"},
} }
r := newRouterWithOpts(&fakeSvc{}, client, true) 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 { if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code) t.Fatalf("expected 200, got %d", w.Code)
} }

View File

@ -73,7 +73,7 @@ type FirebaseOAuthRes struct {
// FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin. // FirebaseAdminClaimReq is the request body for POST /api/v1/oauth/firebase/claim-admin.
type FirebaseAdminClaimReq struct { type FirebaseAdminClaimReq struct {
IDToken string `json:"id_token" binding:"required" example:"<firebase-id-token>"` UID string `json:"uid" binding:"required" example:"abc123"`
} }
// FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin. // FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin.