feat: initial commit for media tracking software

- Setup C++/Qt6 desktop application structure
- Added initial Go proxy backend for TMDB/IGDB integration
This commit is contained in:
Tronax 2026-06-15 02:02:25 +02:00
commit 3f07c78316
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
63 changed files with 6547 additions and 0 deletions

33
server/.env.example Normal file
View file

@ -0,0 +1,33 @@
# --- umt-tmdb-proxy configuration ---------------------------------------
# Copy to .env and fill in. All values are read from the environment.
# Where the server listens (inside the container / on the host).
LISTEN_ADDR=:8080
# Externally reachable base URL of this proxy (no trailing slash).
# This is also what users enter as "Proxy-URL" in the desktop app.
PUBLIC_URL=https://tmdb.example.com
# The single shared TMDB v3 API key the whole group will use.
TMDB_API_KEY=your_tmdb_v3_key
# --- Authentik / OIDC ---------------------------------------------------
# Issuer is the OIDC provider URL from your Authentik application
# (e.g. https://auth.example.com/application/o/watchlist-proxy/).
OIDC_ISSUER=https://auth.example.com/application/o/watchlist-proxy/
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
# Must exactly match a Redirect URI configured in Authentik.
# Defaults to PUBLIC_URL + /callback if left empty.
OIDC_REDIRECT_URL=https://tmdb.example.com/callback
# Optional: restrict access to members of these Authentik groups
# (comma-separated). Requires a "groups" scope/claim mapping in Authentik.
# ALLOWED_GROUPS=watchlist,friends
# A long random secret used to sign issued access tokens. KEEP IT PRIVATE.
# Generate with: openssl rand -base64 48
SESSION_SECRET=change-me-to-a-long-random-string
# How long an issued desktop-app token stays valid (in days).
TOKEN_TTL_DAYS=90

14
server/Dockerfile Normal file
View file

@ -0,0 +1,14 @@
# --- build stage ---
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /umt-tmdb-proxy .
# --- run stage ---
FROM gcr.io/distroless/static-debian12
COPY --from=build /umt-tmdb-proxy /umt-tmdb-proxy
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/umt-tmdb-proxy"]

69
server/README.md Normal file
View file

@ -0,0 +1,69 @@
# UMT TMDB Proxy
A small Go server that lets a group share **one** TMDB API key. Access is gated
by **OIDC via Authentik**: each user signs in once and receives a personal token
that they paste into the Ultimate Media Tracker desktop app.
```
desktop app ──Bearer token──▶ proxy ──?api_key=SHARED──▶ api.themoviedb.org
└── login gated by Authentik (OIDC)
```
## How it works
1. A user opens the proxy URL and clicks **Mit Authentik anmelden**.
2. The server runs the OIDC Authorization-Code + PKCE flow against Authentik and
verifies the returned ID token (signature, issuer, audience, expiry).
3. On success it mints a stateless, HMAC-signed token and shows it on a page.
4. The user enters the proxy URL + token into the app (*Einstellungen → Filme/
Serien über → Proxy-Server*).
5. Every `/3/...` request from the app is validated and forwarded to TMDB with
the shared key appended. (Poster images come straight from the public
`image.tmdb.org` and need no key.)
## Configure
Copy `.env.example` to `.env` and fill it in. Required: `TMDB_API_KEY`,
`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET`.
### Authentik setup
Create an **OAuth2/OpenID Provider** + **Application**:
- Client type: **Confidential**
- Redirect URI: `https://tmdb.example.com/callback` (your `OIDC_REDIRECT_URL`)
- Scopes: `openid`, `profile`, `email` (add a `groups` mapping if you use
`ALLOWED_GROUPS`)
- Copy the Client ID / Secret and the provider's **OpenID Configuration Issuer**
into `.env`.
## Run
### With Go
```bash
go mod tidy # once, to fetch dependencies
set -a; . ./.env; set +a
go run .
```
### With Docker
```bash
docker build -t umt-tmdb-proxy .
docker run --env-file .env -p 8080:8080 umt-tmdb-proxy
```
Put it behind a TLS-terminating reverse proxy (Caddy, Traefik, nginx) so
`PUBLIC_URL` is `https://…`; cookies are then marked `Secure` automatically.
## Endpoints
| Path | Purpose |
|-------------|----------------------------------------------------|
| `/` | Landing page with a login button |
| `/login` | Starts the Authentik OIDC flow |
| `/callback` | OIDC redirect target; shows the personal token |
| `/3/*` | Authenticated TMDB API proxy |
| `/healthz` | Liveness probe |

89
server/config.go Normal file
View file

@ -0,0 +1,89 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config holds all runtime settings, sourced from environment variables so the
// server is easy to run under systemd, Docker or a PaaS without code changes.
type Config struct {
ListenAddr string // e.g. ":8080"
PublicURL string // externally reachable base URL, e.g. https://tmdb.example.com
TMDBKey string // the single shared TMDB v3 API key
TMDBBase string // upstream base, defaults to https://api.themoviedb.org/3
Issuer string // Authentik OIDC issuer URL
ClientID string // OAuth2 client id
ClientSecret string // OAuth2 client secret
RedirectURL string // OAuth2 redirect URL (PublicURL + /callback if empty)
SessionKey []byte // HMAC secret for signing access tokens / state cookies
AllowGroups []string // if set, the user must be in one of these Authentik groups
TokenTTL time.Duration // lifetime of issued desktop-app tokens
}
func getenv(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func loadConfig() (*Config, error) {
c := &Config{
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
PublicURL: strings.TrimRight(getenv("PUBLIC_URL", "http://localhost:8080"), "/"),
TMDBKey: getenv("TMDB_API_KEY", ""),
TMDBBase: strings.TrimRight(getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"), "/"),
Issuer: getenv("OIDC_ISSUER", ""),
ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
}
if c.RedirectURL == "" {
c.RedirectURL = c.PublicURL + "/callback"
}
secret := getenv("SESSION_SECRET", "")
if secret == "" {
return nil, fmt.Errorf("SESSION_SECRET is required (a long random string)")
}
c.SessionKey = []byte(secret)
if groups := getenv("ALLOWED_GROUPS", ""); groups != "" {
for _, g := range strings.Split(groups, ",") {
if g = strings.TrimSpace(g); g != "" {
c.AllowGroups = append(c.AllowGroups, g)
}
}
}
days, err := strconv.Atoi(getenv("TOKEN_TTL_DAYS", "90"))
if err != nil || days <= 0 {
days = 90
}
c.TokenTTL = time.Duration(days) * 24 * time.Hour
// Required fields.
var missing []string
if c.TMDBKey == "" {
missing = append(missing, "TMDB_API_KEY")
}
if c.Issuer == "" {
missing = append(missing, "OIDC_ISSUER")
}
if c.ClientID == "" {
missing = append(missing, "OIDC_CLIENT_ID")
}
if c.ClientSecret == "" {
missing = append(missing, "OIDC_CLIENT_SECRET")
}
if len(missing) > 0 {
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
}
return c, nil
}

13
server/go.mod Normal file
View file

@ -0,0 +1,13 @@
module umt-tmdb-proxy
go 1.22
require (
github.com/coreos/go-oidc/v3 v3.11.0
golang.org/x/oauth2 v0.21.0
)
require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
golang.org/x/crypto v0.25.0 // indirect
)

18
server/go.sum Normal file
View file

@ -0,0 +1,18 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

318
server/main.go Normal file
View file

@ -0,0 +1,318 @@
// Command umt-tmdb-proxy is a small TMDB API proxy guarded by OIDC (Authentik).
//
// It lets a group of friends share a single TMDB API key: the server holds the
// key, requires each user to sign in once via Authentik, and then issues a
// long-lived token they paste into the Ultimate Media Tracker desktop app.
// All TMDB requests from the app are forwarded with the shared key attached.
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type server struct {
cfg *Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
client *http.Client
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("config error: %v", err)
}
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
log.Fatalf("OIDC discovery failed for %q: %v", cfg.Issuer, err)
}
s := &server{
cfg: cfg,
provider: provider,
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
client: &http.Client{Timeout: 15 * time.Second},
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/3/", s.handleProxy)
log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
func (s *server) secure() bool {
return strings.HasPrefix(s.cfg.PublicURL, "https://")
}
func (s *server) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: s.secure(),
SameSite: http.SameSiteLaxMode,
})
}
func randString(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Media Tracker TMDB-Proxy</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:8vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}a.btn{display:inline-block;background:#0a84ff;color:#fff;
text-decoration:none;padding:12px 22px;border-radius:10px;font-weight:600}
.muted{color:#8e8e93}</style></head><body>
<h1>Ultimate Media Tracker &ndash; TMDB-Proxy</h1>
<p class="muted">Melde dich mit deinem Konto an, um ein Zugriffstoken f&uuml;r die
Desktop-App zu erhalten. Damit nutzt du TMDB, ohne einen eigenen API-Key zu brauchen.</p>
<p><a class="btn" href="/login">Mit Authentik anmelden</a></p>
</body></html>`)
}
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
state := randString(24)
verifier := oauth2.GenerateVerifier()
s.setCookie(w, "umt_state", state, 600)
s.setCookie(w, "umt_verifier", verifier, 600)
// An optional app_redirect lets the desktop app capture the token via a
// loopback URL instead of copy/paste. Only accept localhost targets.
if appcb := r.URL.Query().Get("app_redirect"); isLoopbackRedirect(appcb) {
s.setCookie(w, "umt_appcb", appcb, 600)
} else {
s.setCookie(w, "umt_appcb", "", -1)
}
authURL := s.oauth.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, authURL, http.StatusFound)
}
// isLoopbackRedirect reports whether raw is a plain-HTTP URL pointing at the
// local machine, so it is safe to hand a freshly minted token to.
func isLoopbackRedirect(raw string) bool {
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" {
return false
}
switch u.Hostname() {
case "127.0.0.1", "localhost", "::1":
return true
}
return false
}
func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stateCookie, err := r.Cookie("umt_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "ungültiger oder abgelaufener Login-Versuch", http.StatusBadRequest)
return
}
verifierCookie, err := r.Cookie("umt_verifier")
if err != nil {
http.Error(w, "fehlender PKCE-Verifier", http.StatusBadRequest)
return
}
oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"),
oauth2.VerifierOption(verifierCookie.Value))
if err != nil {
http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
rawIDToken, ok := oauthToken.Extra("id_token").(string)
if !ok {
http.Error(w, "kein id_token in der Antwort", http.StatusBadGateway)
return
}
idToken, err := s.verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized)
return
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"preferred_username"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Claims konnten nicht gelesen werden", http.StatusInternalServerError)
return
}
if !s.groupAllowed(claims.Groups) {
http.Error(w, "Dein Konto ist für diesen Dienst nicht freigeschaltet.", http.StatusForbidden)
return
}
// Clear the transient cookies.
s.setCookie(w, "umt_state", "", -1)
s.setCookie(w, "umt_verifier", "", -1)
appToken, err := mintToken(s.cfg.SessionKey, claims.Subject, claims.Email, s.cfg.TokenTTL)
if err != nil {
http.Error(w, "Token konnte nicht erstellt werden", http.StatusInternalServerError)
return
}
// If the login was started by the desktop app, hand the token back via its
// loopback URL so it is captured automatically.
if appcbCookie, err := r.Cookie("umt_appcb"); err == nil && isLoopbackRedirect(appcbCookie.Value) {
s.setCookie(w, "umt_appcb", "", -1)
cb, _ := url.Parse(appcbCookie.Value)
q := cb.Query()
q.Set("token", appToken)
cb.RawQuery = q.Encode()
http.Redirect(w, r, cb.String(), http.StatusFound)
return
}
who := claims.Name
if who == "" {
who = claims.Email
}
s.renderToken(w, who, appToken)
}
func (s *server) groupAllowed(userGroups []string) bool {
if len(s.cfg.AllowGroups) == 0 {
return true
}
for _, allowed := range s.cfg.AllowGroups {
for _, g := range userGroups {
if g == allowed {
return true
}
}
}
return false
}
func (s *server) renderToken(w http.ResponseWriter, who, token string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dein Zugriffstoken</title>
<style>body{font-family:system-ui,sans-serif;max-width:680px;margin:6vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}code,textarea{font-family:ui-monospace,monospace}
textarea{width:100%;height:120px;background:#1c1c1e;color:#30d158;border:1px solid #3a3a3c;
border-radius:10px;padding:12px;font-size:13px}.muted{color:#8e8e93}
ol{line-height:1.7}</style></head><body>
<h1>Angemeldet als ` + htmlEscape(who) + `</h1>
<p>Kopiere dieses Token und trage es in der Desktop-App unter
<b>Einstellungen &rarr; Filme/Serien &uuml;ber &rarr; Proxy-Server</b> ein:</p>
<textarea readonly onclick="this.select()">` + htmlEscape(token) + `</textarea>
<ol class="muted">
<li>Proxy-URL: <code>` + htmlEscape(s.cfg.PublicURL) + `</code></li>
<li>Proxy-Token: das Feld oben</li>
</ol>
<p class="muted">Das Token ist persönlich und läuft nach einiger Zeit ab &ndash; dann hier erneut anmelden.</p>
</body></html>`)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// handleProxy forwards an authenticated /3/* request to TMDB with the shared key.
func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
return
}
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Build upstream URL: /3/<path> -> <TMDBBase>/<path>, keep the query, force api_key.
upstreamPath := strings.TrimPrefix(r.URL.Path, "/3")
target, err := url.Parse(s.cfg.TMDBBase + upstreamPath)
if err != nil {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
q := r.URL.Query()
q.Set("api_key", s.cfg.TMDBKey)
target.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "request build failed", http.StatusInternalServerError)
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "umt-tmdb-proxy/1.0")
resp, err := s.client.Do(req)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

68
server/token.go Normal file
View file

@ -0,0 +1,68 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
// appClaims is the payload of a desktop-app access token. These tokens are
// minted by this server after a successful Authentik login and are independent
// of the (short-lived) OIDC tokens, so a friend can paste one into the desktop
// app and keep using it until it expires.
type appClaims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Expires int64 `json:"exp"` // unix seconds
}
// mintToken creates a stateless, HMAC-signed token of the form
// base64url(payload).base64url(sig). No server-side storage is needed.
func mintToken(secret []byte, sub, email string, ttl time.Duration) (string, error) {
payload := appClaims{
Subject: sub,
Email: email,
Expires: time.Now().Add(ttl).Unix(),
}
raw, err := json.Marshal(payload)
if err != nil {
return "", err
}
body := base64.RawURLEncoding.EncodeToString(raw)
sig := sign(secret, body)
return body + "." + sig, nil
}
// verifyToken validates the signature and expiry, returning the claims.
func verifyToken(secret []byte, token string) (*appClaims, error) {
parts := strings.SplitN(strings.TrimSpace(token), ".", 2)
if len(parts) != 2 {
return nil, errors.New("malformed token")
}
expected := sign(secret, parts[0])
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return nil, errors.New("bad signature")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, errors.New("bad payload encoding")
}
var claims appClaims
if err := json.Unmarshal(raw, &claims); err != nil {
return nil, errors.New("bad payload")
}
if time.Now().Unix() >= claims.Expires {
return nil, errors.New("token expired")
}
return &claims, nil
}
func sign(secret []byte, body string) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte(body))
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}