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 }`)
- `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":"<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 \
-H "Content-Type: application/json" \
-d '{"id_token":"<firebase-id-token>"}'
-d '{"uid":"<firebase-uid>"}'
# Delete a show with auth
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": {
"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"
}
}
},

View File

@ -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"
}
}
},

View File

@ -68,11 +68,11 @@ definitions:
type: object
httpapi.FirebaseAdminClaimReq:
properties:
id_token:
example: <firebase-id-token>
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:

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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:"<firebase-id-token>"`
UID string `json:"uid" binding:"required" example:"abc123"`
}
// FirebaseAdminClaimRes is the response body for POST /api/v1/oauth/firebase/claim-admin.