# Identify & SSO

By default the widget captures anonymous feedback. To attach a **verified** user
identity — so submissions are tied to a real account and votes are deduped per
person — call `identify` with an `auth` token you sign **on your server**.

> **Never ship your signing secret to the browser.** The browser only ever sees the
> already-signed token.

## The flow

1. Your backend signs a short-lived token over a **canonical identity message**.
2. You hand `{ userId, email, auth }` to the browser.
3. You call `luuphub('identify', { userId, email, auth })`.
4. Luuphub verifies the signature and binds the identity to the session.

## Why a canonical message (not `userId + email`)

Concatenating identity fields is ambiguous — `("alice", "@b.com")` and
`("alice@b.com", "")` both concatenate to the same string, so one signed pair's
signature would verify for a *different* pair (identity confusion → takeover).
Every signer below produces a byte-identical **canonical JSON message** with a
FIXED field order:

```text
{"v":1,"aud":"luuphub-identify","sub":<userId>,"email":<email>,"iat":<unix>,"exp":<unix>}
```

- `aud` domain-separates the signature to the identify use-case.
- `sub` / `email` are always present as strings (empty when absent).
- `hmac` = base64url(HMAC-SHA256(secret, canonical)); the token is also emitted as
  an HS256 **JWT** over its own `header.payload`.
- **Keep it short-lived.** `exp - iat` must be at most **300 seconds** —
  the server rejects a longer window. A captured token is only usable for minutes.

## Reference signers (five languages)

Each snippet is the exact, copy-paste signer from our repo. They all emit
byte-identical canonical output for ASCII input. Provide `claimsJson` as
`{"sub":"u_123","email":"a@b.com","iat":<unix>,"exp":<unix>}`; each prints
`{ canonical, hmac, jwt }`.

### Node.js

Run it: `node sign.node.mjs <secret> '<claimsJson>'`

```js
// Luuphub identify signing — Node.js reference (R16/R69).
//
// Run in YOUR backend to sign an identify payload with your per-site secret, then
// hand { userId, email, auth } to the browser for `luuphub('identify', …)`.
//
//   node sign.node.mjs <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. The canonical HMAC message has a FIXED field
// order + iat/exp (never a userId+email concat); the JWT is HS256.
import { createHmac } from 'node:crypto';

const AUD = 'luuphub-identify';
const V = 1;

const secret = process.argv[2];
const claims = JSON.parse(process.argv[3]);
const sub = String(claims.sub ?? '');
const email = String(claims.email ?? '');
const iat = Number(claims.iat);
const exp = Number(claims.exp);

const b64url = (buf) => Buffer.from(buf).toString('base64url');
const jstr = (s) => JSON.stringify(s); // JSON string escaping (matches all snippets for ASCII)

// FIXED-ORDER canonical message — the exact bytes the server re-derives + checks.
const canonical =
  `{"v":${V},"aud":${jstr(AUD)},"sub":${jstr(sub)},"email":${jstr(email)},"iat":${iat},"exp":${exp}}`;
const hmac = createHmac('sha256', secret).update(canonical, 'utf8').digest('base64url');

// HS256 JWT (self-contained: the server re-signs the token's own header.payload).
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = b64url(JSON.stringify({ aud: AUD, sub, email, iat, exp }));
const signingInput = `${header}.${payload}`;
const jwtSig = createHmac('sha256', secret).update(signingInput, 'utf8').digest('base64url');
const jwt = `${signingInput}.${jwtSig}`;

process.stdout.write(JSON.stringify({ canonical, hmac, jwt }));
```

### Python

Run it: `python3 sign.py <secret> '<claimsJson>'`

```python
# Luuphub identify signing — Python reference (R16/R69).
#
# Run in YOUR backend to sign an identify payload with your per-site secret.
#
#   python3 sign.py <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
#
# Prints { canonical, hmac, jwt }. The canonical HMAC message has a FIXED field
# order + iat/exp (never a userId+email concat); the JWT is HS256.
import sys
import json
import hmac
import hashlib
import base64

AUD = "luuphub-identify"
V = 1

secret = sys.argv[1]
claims = json.loads(sys.argv[2])
sub = str(claims.get("sub", ""))
email = str(claims.get("email", ""))
iat = int(claims["iat"])
exp = int(claims["exp"])


def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()


def jstr(s: str) -> str:
    # ensure_ascii=False so ASCII output matches the other snippets byte-for-byte.
    return json.dumps(s, ensure_ascii=False)


# FIXED-ORDER canonical message — the exact bytes the server re-derives + checks.
canonical = '{"v":%d,"aud":%s,"sub":%s,"email":%s,"iat":%d,"exp":%d}' % (
    V,
    jstr(AUD),
    jstr(sub),
    jstr(email),
    iat,
    exp,
)
hmac_sig = b64url(hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest())

# HS256 JWT (self-contained: the server re-signs the token's own header.payload).
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(
    json.dumps(
        {"aud": AUD, "sub": sub, "email": email, "iat": iat, "exp": exp},
        separators=(",", ":"),
    ).encode()
)
signing_input = header + "." + payload
jwt_sig = b64url(hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).digest())
jwt = signing_input + "." + jwt_sig

print(json.dumps({"canonical": canonical, "hmac": hmac_sig, "jwt": jwt}))
```

### Ruby

Run it: `ruby sign.rb <secret> '<claimsJson>'`

```ruby
# Luuphub identify signing — Ruby reference (R16/R69).
#
#   ruby sign.rb <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
#
# Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
# + iat/exp (never a userId+email concat); the JWT is HS256.
require "json"
require "openssl"
require "base64"

AUD = "luuphub-identify"
V = 1

secret = ARGV[0]
claims = JSON.parse(ARGV[1])
sub = (claims["sub"] || "").to_s
email = (claims["email"] || "").to_s
iat = claims["iat"].to_i
exp = claims["exp"].to_i

def b64url(bytes)
  Base64.urlsafe_encode64(bytes).delete("=")
end

def jstr(str)
  str.to_json
end

canonical = %({"v":#{V},"aud":#{jstr(AUD)},"sub":#{jstr(sub)},"email":#{jstr(email)},"iat":#{iat},"exp":#{exp}})
hmac_sig = b64url(OpenSSL::HMAC.digest("SHA256", secret, canonical))

header = b64url({ alg: "HS256", typ: "JWT" }.to_json)
payload = b64url({ aud: AUD, sub: sub, email: email, iat: iat, exp: exp }.to_json)
signing_input = "#{header}.#{payload}"
jwt_sig = b64url(OpenSSL::HMAC.digest("SHA256", secret, signing_input))
jwt = "#{signing_input}.#{jwt_sig}"

print({ canonical: canonical, hmac: hmac_sig, jwt: jwt }.to_json)
```

### PHP

Run it: `php sign.php <secret> '<claimsJson>'`

```php
<?php
// Luuphub identify signing — PHP reference (R16/R69).
//
//   php sign.php <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
// + iat/exp (never a userId+email concat); the JWT is HS256.

$AUD = 'luuphub-identify';
$V = 1;

$secret = $argv[1];
$claims = json_decode($argv[2], true);
$sub = (string) ($claims['sub'] ?? '');
$email = (string) ($claims['email'] ?? '');
$iat = (int) $claims['iat'];
$exp = (int) $claims['exp'];

function b64url(string $bytes): string {
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

// Match the other snippets: no slash/unicode escaping (JS never escapes '/').
function jstr(string $s): string {
    return json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

$canonical = '{"v":' . $V . ',"aud":' . jstr($AUD) . ',"sub":' . jstr($sub)
    . ',"email":' . jstr($email) . ',"iat":' . $iat . ',"exp":' . $exp . '}';
$hmac = b64url(hash_hmac('sha256', $canonical, $secret, true));

$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$header = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT'], $flags));
$payload = b64url(json_encode(
    ['aud' => $AUD, 'sub' => $sub, 'email' => $email, 'iat' => $iat, 'exp' => $exp],
    $flags
));
$signingInput = "$header.$payload";
$jwtSig = b64url(hash_hmac('sha256', $signingInput, $secret, true));
$jwt = "$signingInput.$jwtSig";

echo json_encode(['canonical' => $canonical, 'hmac' => $hmac, 'jwt' => $jwt], $flags);
```

### Go

Run it: `go run sign.go <secret> '<claimsJson>'`

```go
// Luuphub identify signing — Go reference (R16/R69).
//
//	go run sign.go <secret> '{"sub":"u_123","email":"a@b.com","iat":1712345678,"exp":1712345798}'
//
// Prints { canonical, hmac, jwt }. Canonical HMAC message has a FIXED field order
// + iat/exp (never a userId+email concat); the JWT is HS256.
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"os"
)

const aud = "luuphub-identify"
const version = 1

func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }

func jstr(s string) string {
	b, _ := json.Marshal(s)
	return string(b)
}

func mac(secret, msg string) []byte {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write([]byte(msg))
	return h.Sum(nil)
}

func main() {
	secret := os.Args[1]
	var claims map[string]interface{}
	if err := json.Unmarshal([]byte(os.Args[2]), &claims); err != nil {
		panic(err)
	}
	sub, _ := claims["sub"].(string)
	email, _ := claims["email"].(string)
	iat := int64(claims["iat"].(float64))
	exp := int64(claims["exp"].(float64))

	canonical := fmt.Sprintf(`{"v":%d,"aud":%s,"sub":%s,"email":%s,"iat":%d,"exp":%d}`,
		version, jstr(aud), jstr(sub), jstr(email), iat, exp)
	hmacSig := b64url(mac(secret, canonical))

	header := b64url([]byte(`{"alg":"HS256","typ":"JWT"}`))
	payloadBytes, _ := json.Marshal(map[string]interface{}{
		"aud": aud, "sub": sub, "email": email, "iat": iat, "exp": exp,
	})
	payload := b64url(payloadBytes)
	signingInput := header + "." + payload
	jwt := signingInput + "." + b64url(mac(secret, signingInput))

	out, _ := json.Marshal(map[string]string{"canonical": canonical, "hmac": hmacSig, "jwt": jwt})
	fmt.Print(string(out))
}
```

## On the client

```js
// 'auth' is the 'jwt' (or 'hmac') your backend just produced.
luuphub('identify', { userId: 'u_123', email: 'a@b.com', auth });
```

The server re-derives the canonical message, checks the signature with your
per-site secret, and enforces the expiry window before trusting the identity. A
forged, wrong-secret, `alg:none`, or expired token is rejected.
