Skip to content

Commit 840a898

Browse files
committed
feat: add Meta App Review access mode
- Add token-gated, short-lived reviewer sessions outside LINE LIFF\n- Preserve review authentication through Facebook OAuth redirects\n- Keep Rich Menu actions disabled for review sessions and document Railway configuration\n- Cover review session creation, expiration, and protected API access\n\nThis gives Meta reviewers a revocable browser-based test flow without weakening normal LINE authentication.
1 parent d850a6a commit 840a898

13 files changed

Lines changed: 357 additions & 16 deletions

File tree

‎apps/api/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ APP_URL=http://localhost:5173
33
API_URL=http://localhost:8080
44
PORT=8080
55

6+
# Enable only while Meta reviews the app. Use a long random value and share
7+
# the resulting review URL only through the Meta App Review submission.
8+
META_REVIEW_ENABLED=false
9+
META_REVIEW_TOKEN=
10+
611
DB_DSN=host=127.0.0.1 user=linora password=linora dbname=linora port=5433 sslmode=disable TimeZone=Asia/Bangkok
712
REDIS_URL=redis://127.0.0.1:6379/0
813

‎apps/api/internal/config/config.go‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,19 @@ type LineConfig struct {
3232
DashboardRichMenuID string
3333
}
3434

35+
type MetaReviewConfig struct {
36+
Enabled bool
37+
Token string
38+
}
39+
3540
type Config struct {
3641
AI AIConfig
3742
DatabaseDSN string
3843
EncryptionKey string
3944
Environment string
4045
Facebook FacebookConfig
4146
Line LineConfig
47+
MetaReview MetaReviewConfig
4248
Port string
4349
RedisURL string
4450
}
@@ -78,16 +84,30 @@ func Load() (Config, error) {
7884
ConnectRichMenuID: os.Getenv("LINE_RICH_MENU_CONNECT_ID"),
7985
DashboardRichMenuID: os.Getenv("LINE_RICH_MENU_DASHBOARD_ID"),
8086
},
87+
MetaReview: MetaReviewConfig{
88+
Enabled: strings.EqualFold(strings.TrimSpace(os.Getenv("META_REVIEW_ENABLED")), "true"),
89+
Token: strings.TrimSpace(os.Getenv("META_REVIEW_TOKEN")),
90+
},
8191
}
8292
if err := cfg.AI.Validate(); err != nil {
8393
return Config{}, err
8494
}
8595
if err := cfg.Facebook.Validate(); err != nil {
8696
return Config{}, err
8797
}
98+
if err := cfg.MetaReview.Validate(); err != nil {
99+
return Config{}, err
100+
}
88101
return cfg, nil
89102
}
90103

104+
func (c MetaReviewConfig) Validate() error {
105+
if c.Enabled && c.Token == "" {
106+
return fmt.Errorf("missing required Meta review environment variable: META_REVIEW_TOKEN")
107+
}
108+
return nil
109+
}
110+
91111
func (c FacebookConfig) Validate() error {
92112
missing := make([]string, 0, 6)
93113
if strings.TrimSpace(c.AppID) == "" {

‎apps/api/internal/config/config_test.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,15 @@ func TestFacebookConfigValidateRequiresEveryEnvironmentValue(t *testing.T) {
3535
}
3636
}
3737
}
38+
39+
func TestMetaReviewConfigRequiresTokenOnlyWhenEnabled(t *testing.T) {
40+
if err := (MetaReviewConfig{}).Validate(); err != nil {
41+
t.Fatalf("disabled review mode should be valid: %v", err)
42+
}
43+
if err := (MetaReviewConfig{Enabled: true}).Validate(); err == nil {
44+
t.Fatal("enabled review mode should require a token")
45+
}
46+
if err := (MetaReviewConfig{Enabled: true, Token: "review-token"}).Validate(); err != nil {
47+
t.Fatalf("enabled review mode with a token should be valid: %v", err)
48+
}
49+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package controllers
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/fulltank-garage/linora/apps/api/internal/services"
7+
"github.com/gin-gonic/gin"
8+
)
9+
10+
type MetaReviewController struct {
11+
sessions *services.MetaReviewSessionService
12+
}
13+
14+
func NewMetaReviewController(sessions *services.MetaReviewSessionService) *MetaReviewController {
15+
return &MetaReviewController{sessions: sessions}
16+
}
17+
18+
func (c *MetaReviewController) CreateSession(ctx *gin.Context) {
19+
var input struct {
20+
Token string `json:"token"`
21+
}
22+
if err := ctx.ShouldBindJSON(&input); err != nil {
23+
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid Meta review request"})
24+
return
25+
}
26+
session, err := c.sessions.Create(input.Token)
27+
if err != nil {
28+
ctx.JSON(http.StatusNotFound, gin.H{"error": "Meta review access is unavailable"})
29+
return
30+
}
31+
ctx.JSON(http.StatusOK, gin.H{"session": session})
32+
}

‎apps/api/internal/middleware/cors.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func CORS(appURL string) gin.HandlerFunc {
2020
if allowedOrigins[origin] {
2121
c.Header("Access-Control-Allow-Origin", origin)
2222
c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
23-
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Linora-Dev-User")
23+
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Linora-Dev-User, X-Linora-Review-Session")
2424
}
2525

2626
if c.Request.Method == http.MethodOptions {

‎apps/api/internal/middleware/line_identity.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,36 @@ import (
1010

1111
const lineUserIDKey = "linora.lineUserID"
1212

13+
const MetaReviewSessionHeader = "X-Linora-Review-Session"
14+
1315
type LineIdentityVerifier interface {
1416
VerifyIDToken(context.Context, string) (string, error)
1517
}
1618

19+
type MetaReviewSessionVerifier interface {
20+
Verify(string) (string, error)
21+
}
22+
1723
func RequireLineIdentity(verifier LineIdentityVerifier, environment string) gin.HandlerFunc {
24+
return RequireIdentity(verifier, environment, nil)
25+
}
26+
27+
// RequireIdentity accepts the normal LINE identity or, when explicitly
28+
// enabled, a short-lived Meta app-review session.
29+
func RequireIdentity(verifier LineIdentityVerifier, environment string, review MetaReviewSessionVerifier) gin.HandlerFunc {
1830
return func(c *gin.Context) {
31+
if review != nil {
32+
if session := strings.TrimSpace(c.GetHeader(MetaReviewSessionHeader)); session != "" {
33+
userID, err := review.Verify(session)
34+
if err != nil {
35+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Meta review session has expired. Please use the review link again."})
36+
return
37+
}
38+
c.Set(lineUserIDKey, userID)
39+
c.Next()
40+
return
41+
}
42+
}
1943
if strings.EqualFold(environment, "development") {
2044
if userID := strings.TrimSpace(c.GetHeader("X-Linora-Dev-User")); userID != "" {
2145
c.Set(lineUserIDKey, userID)

‎apps/api/internal/routes/router.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ func NewRouter(cfg config.Config, analysisService *services.AnalysisService, fac
2323
router.Use(middleware.RateLimit(apiRequestsPerMinute, time.Minute))
2424

2525
facebookController := controllers.NewFacebookController(facebookService, pageService)
26-
requireLineIdentity := middleware.RequireLineIdentity(lineIdentity, cfg.Environment)
26+
metaReviewSessions := services.NewMetaReviewSessionService(cfg.MetaReview)
27+
metaReviewController := controllers.NewMetaReviewController(metaReviewSessions)
28+
requireLineIdentity := middleware.RequireIdentity(lineIdentity, cfg.Environment, metaReviewSessions)
2729
if pageService != nil && lineService != nil {
2830
integrationController := controllers.NewIntegrationController(pageService, lineService, cfg.Line.ChannelSecret, middleware.NewRateLimiter(lineMessagesPerUserPerMinute, time.Minute))
2931
facebook := router.Group("/api/facebook", requireLineIdentity)
@@ -48,6 +50,7 @@ func NewRouter(cfg config.Config, analysisService *services.AnalysisService, fac
4850
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "linora-api"})
4951
})
5052
router.POST("/api/facebook/login", requireLineIdentity, facebookController.Begin)
53+
router.POST("/api/meta-review/session", metaReviewController.CreateSession)
5154
router.GET("/api/facebook/callback", facebookController.Callback)
5255
router.GET("/api/facebook/session", requireLineIdentity, facebookController.Session)
5356
router.POST("/api/facebook/deauthorize", facebookController.Deauthorize)

‎apps/api/internal/routes/router_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package routes
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"net/http"
57
"net/http/httptest"
68
"testing"
79

810
"github.com/fulltank-garage/linora/apps/api/internal/config"
11+
"github.com/fulltank-garage/linora/apps/api/internal/middleware"
912
"github.com/fulltank-garage/linora/apps/api/internal/services"
1013
)
1114

@@ -39,3 +42,32 @@ func TestFacebookDeauthorizeRouteIsPublic(t *testing.T) {
3942
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
4043
}
4144
}
45+
46+
func TestMetaReviewSessionAllowsFacebookLoginWithoutLINE(t *testing.T) {
47+
cfg := config.Config{
48+
Environment: "production",
49+
MetaReview: config.MetaReviewConfig{Enabled: true, Token: "review-access-token"},
50+
Port: "8080",
51+
}
52+
router := NewRouter(cfg, services.NewAnalysisService(), services.NewFacebookService(cfg.Facebook), nil, nil, services.NewLineIdentityService(cfg.Line))
53+
payload, _ := json.Marshal(map[string]string{"token": "review-access-token"})
54+
createRecorder := httptest.NewRecorder()
55+
router.ServeHTTP(createRecorder, httptest.NewRequest(http.MethodPost, "/api/meta-review/session", bytes.NewReader(payload)))
56+
if createRecorder.Code != http.StatusOK {
57+
t.Fatalf("session status = %d, want %d", createRecorder.Code, http.StatusOK)
58+
}
59+
var sessionResponse struct {
60+
Session string `json:"session"`
61+
}
62+
if err := json.Unmarshal(createRecorder.Body.Bytes(), &sessionResponse); err != nil || sessionResponse.Session == "" {
63+
t.Fatalf("invalid session response: %v", err)
64+
}
65+
66+
loginRequest := httptest.NewRequest(http.MethodPost, "/api/facebook/login", nil)
67+
loginRequest.Header.Set(middleware.MetaReviewSessionHeader, sessionResponse.Session)
68+
loginRecorder := httptest.NewRecorder()
69+
router.ServeHTTP(loginRecorder, loginRequest)
70+
if loginRecorder.Code != http.StatusServiceUnavailable {
71+
t.Fatalf("login status = %d, want %d", loginRecorder.Code, http.StatusServiceUnavailable)
72+
}
73+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package services
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"crypto/subtle"
7+
"encoding/base64"
8+
"encoding/json"
9+
"errors"
10+
"strings"
11+
"time"
12+
13+
"github.com/fulltank-garage/linora/apps/api/internal/config"
14+
)
15+
16+
const MetaReviewSessionLifetime = 8 * time.Hour
17+
18+
var ErrMetaReviewUnavailable = errors.New("Meta review access is unavailable")
19+
var ErrInvalidMetaReviewToken = errors.New("invalid Meta review access")
20+
21+
type metaReviewSessionPayload struct {
22+
ExpiresAt int64 `json:"exp"`
23+
OwnerID string `json:"sub"`
24+
}
25+
26+
// MetaReviewSessionService creates short-lived identities for Meta reviewers.
27+
// It is enabled explicitly through environment variables and does not change
28+
// the normal LINE LIFF authentication flow.
29+
type MetaReviewSessionService struct {
30+
enabled bool
31+
now func() time.Time
32+
secret []byte
33+
ownerID string
34+
}
35+
36+
func NewMetaReviewSessionService(cfg config.MetaReviewConfig) *MetaReviewSessionService {
37+
token := strings.TrimSpace(cfg.Token)
38+
digest := sha256.Sum256([]byte(token))
39+
return &MetaReviewSessionService{
40+
enabled: cfg.Enabled && token != "",
41+
now: time.Now,
42+
secret: []byte(token),
43+
ownerID: "meta-review:" + base64.RawURLEncoding.EncodeToString(digest[:12]),
44+
}
45+
}
46+
47+
func (s *MetaReviewSessionService) Create(accessToken string) (string, error) {
48+
if !s.enabled {
49+
return "", ErrMetaReviewUnavailable
50+
}
51+
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(accessToken)), s.secret) != 1 {
52+
return "", ErrInvalidMetaReviewToken
53+
}
54+
payload, err := json.Marshal(metaReviewSessionPayload{
55+
ExpiresAt: s.now().Add(MetaReviewSessionLifetime).Unix(),
56+
OwnerID: s.ownerID,
57+
})
58+
if err != nil {
59+
return "", err
60+
}
61+
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
62+
mac := hmac.New(sha256.New, s.secret)
63+
_, _ = mac.Write([]byte(encodedPayload))
64+
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
65+
}
66+
67+
func (s *MetaReviewSessionService) Verify(session string) (string, error) {
68+
if !s.enabled {
69+
return "", ErrMetaReviewUnavailable
70+
}
71+
parts := strings.Split(session, ".")
72+
if len(parts) != 2 {
73+
return "", ErrInvalidMetaReviewToken
74+
}
75+
providedMAC, err := base64.RawURLEncoding.DecodeString(parts[1])
76+
if err != nil {
77+
return "", ErrInvalidMetaReviewToken
78+
}
79+
mac := hmac.New(sha256.New, s.secret)
80+
_, _ = mac.Write([]byte(parts[0]))
81+
if !hmac.Equal(providedMAC, mac.Sum(nil)) {
82+
return "", ErrInvalidMetaReviewToken
83+
}
84+
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
85+
if err != nil {
86+
return "", ErrInvalidMetaReviewToken
87+
}
88+
var payload metaReviewSessionPayload
89+
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.OwnerID != s.ownerID || payload.ExpiresAt <= s.now().Unix() {
90+
return "", ErrInvalidMetaReviewToken
91+
}
92+
return payload.OwnerID, nil
93+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package services
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/fulltank-garage/linora/apps/api/internal/config"
8+
)
9+
10+
func TestMetaReviewSessionLifecycle(t *testing.T) {
11+
service := NewMetaReviewSessionService(config.MetaReviewConfig{Enabled: true, Token: "review-access-token"})
12+
service.now = func() time.Time { return time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC) }
13+
14+
session, err := service.Create("review-access-token")
15+
if err != nil {
16+
t.Fatalf("Create returned error: %v", err)
17+
}
18+
ownerID, err := service.Verify(session)
19+
if err != nil {
20+
t.Fatalf("Verify returned error: %v", err)
21+
}
22+
if ownerID == "" || ownerID[:12] != "meta-review:" {
23+
t.Fatalf("owner ID = %q, want a meta review owner", ownerID)
24+
}
25+
if _, err := service.Create("wrong-token"); err == nil {
26+
t.Fatal("Create accepted an invalid token")
27+
}
28+
}
29+
30+
func TestMetaReviewSessionExpires(t *testing.T) {
31+
service := NewMetaReviewSessionService(config.MetaReviewConfig{Enabled: true, Token: "review-access-token"})
32+
now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
33+
service.now = func() time.Time { return now }
34+
session, err := service.Create("review-access-token")
35+
if err != nil {
36+
t.Fatalf("Create returned error: %v", err)
37+
}
38+
service.now = func() time.Time { return now.Add(MetaReviewSessionLifetime + time.Second) }
39+
if _, err := service.Verify(session); err == nil {
40+
t.Fatal("Verify accepted an expired session")
41+
}
42+
}

0 commit comments

Comments
 (0)