Unlocking content

When a user has purchased a post, the unlocked content can be displayed using the sesamy-content-container component. The content can either be embedded directly on the page or fetched from the server using a JWT token.

The signed link has two query string parameters, the token containing a jwt and a se parameters that contains the expiry of the link.

Signed Link JWT Token Authentication

In the latest version, we use a JWT (JSON Web Token) for authentication instead of signed URLs. This token is signed with the same keys used in the previous version.

JWT Payload Structure

The JWT payload contains the following information:

json
Copy
{
  "url": "https://example.com/article1",
  "n": "Firstname Lastname",
  "i": "google-oauth2|108791004651072817794",
  "e": "customer@example.com.com",
  "s": "url:d842745d1a55afc198580015ef713df0",
  "p": "ingest_own_article_publisher_default_SEK3900",
  "pid": "99258",
  "iat": 1726583215,
  "exp": 1726597615
}
  • url: The URL of the purchased article
  • n: The full name of the user
  • e: The email of the user
  • s: The SKU of the article
  • p: The purchase option ID
  • pid: The publisher content ID
  • iat: The timestamp when the token was created
  • exp: The expiration timestamp of the token

Validation of the JWT

The JWTs are signed with an RSA256 asymmetric key that can be verified with the public Sesamy key. The public key is published in a JWKS format here: https://assets.sesamy.com/vault-jwks.json

Example: Verifying the JWT in Node.js with TypeScript

import { verify, createPublicKey } from 'crypto';
import jwkToPem from 'jwk-to-pem';
import jwt from 'jsonwebtoken';

async function fetchPublicKey() {
  const response = await fetch('https://assets.sesamy.com/vault-jwks.json');
  if (!response.ok) {
    throw new Error('Failed to fetch public key');
  }
  return response.json();
}

async function getPublicKey() {
  const jwks = await fetchPublicKey();
  const pemKey = jwkToPem(jwks);
  return createPublicKey(pemKey);
}

async function verifyJWT(token: string) {
  const publicKey = await getPublicKey();
  try {
    const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
    return decoded;
  } catch (error) {
    throw new Error('Invalid JWT');
  }
}

Example: Verifying the JWT in PHP

<?php

require __DIR__ . '/vendor/autoload.php';

use Jose\Component\Core\JWK;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Signature\Algorithm\RS256;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;

function verify_jwt($jwt)
{
    $algorithm_manager = new AlgorithmManager([new RS256()]);
    $jwk = JWK::createFromJson('{
        "kty": "RSA",
        "n": "sFATY4fG4n822Zn8bQpszyF9navI_O5lwEg12fEHJGq69EKEfX1xFBXYNj8xEg6ROe4Zl-ssG1Co3Mb3M8zSE9shGSNmMB86oqPOZ9RZTYmiGg_Uh6FqGuP_-SzUC6k8gGVzoo1gn06dqv_S06cT7GW616T57DVHS280FPZ1JLmu88VaBhY_8kgCAqEWgdveLYYWzJhuiTcocCUVRbIElKwWzLbze4BpUQtLQmW5QL-zwYOYXlbamnN-2VP7ZshTqRZEG-LCwI9DEWVUZsdSBdDtG0xH8aTf1BxCAcdcFdPJM2lNa9DmQnNlcB420jL3vKu2mFxxE1Zn_5PIu19pmQ",
        "e": "AQAB"
    }');

    $jwsVerifier = new JWSVerifier($algorithm_manager);
    $serializer = new CompactSerializer();

    $jws = $serializer->unserialize($jwt);
    $isVerified = $jwsVerifier->verifyWithKey($jws, $jwk, 0);

    if (!$isVerified) {
        throw new Exception('Invalid JWT');
    }

    $payload = json_decode($jws->getPayload(), true);

    if ($payload['exp'] < time()) {
        throw new Exception('JWT has expired');
    }

    return $payload;
}
 

Example: Verifying the JWT in .NET (C#)

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json.Linq;

public class JwtValidator
{
    private static readonly HttpClient client = new HttpClient();
    private static readonly string jwksUrl = "https://assets.sesamy.com/vault-jwks.json";

    public static async Task<JwtSecurityToken> VerifyJwtAsync(string token)
    {
        var jwks = await GetJwksAsync();
        var key = GetRsaSecurityKey(jwks);

        var tokenHandler = new JwtSecurityTokenHandler();
        var validationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = key,
            ValidateIssuer = false,
            ValidateAudience = false,
            ClockSkew = TimeSpan.Zero
        };

        try
        {
            tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken);
            return (JwtSecurityToken)validatedToken;
        }
        catch (Exception)
        {
            throw new Exception("Invalid JWT");
        }
    }

    private static async Task<JObject> GetJwksAsync()
    {
        var response = await client.GetStringAsync(jwksUrl);
        return JObject.Parse(response);
    }

    private static RsaSecurityKey GetRsaSecurityKey(JObject jwks)
    {
        var n = Base64UrlEncoder.DecodeBytes(jwks["n"].ToString());
        var e = Base64UrlEncoder.DecodeBytes(jwks["e"].ToString());

        var rsaParameters = new RSAParameters
        {
            Modulus = n,
            Exponent = e
        };

        return new RsaSecurityKey(rsaParameters);
    }
}

Example: Verifying the JWT in Java

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import org.json.JSONObject;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class JwtValidator {
    private static final String JWKS_URL = "https://assets.sesamy.com/vault-jwks.json";

    public static DecodedJWT verifyJwt(String token) throws Exception {
        JSONObject jwks = getJwks();
        RSAPublicKey publicKey = getRsaPublicKey(jwks);

        Algorithm algorithm = Algorithm.RSA256(publicKey, null);
        JWTVerifier verifier = JWT.require(algorithm).build();

        return verifier.verify(token);
    }

    private static JSONObject getJwks() throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(JWKS_URL))
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        return new JSONObject(response.body());
    }

    private static RSAPublicKey getRsaPublicKey(JSONObject jwks) throws Exception {
        String modulusBase64 = jwks.getString("n");
        String exponentBase64 = jwks.getString("e");

        byte[] modulusBytes = Base64.getUrlDecoder().decode(modulusBase64);
        byte[] exponentBytes = Base64.getUrlDecoder().decode(exponentBase64);

        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec(modulusBytes);

        return (RSAPublicKey) keyFactory.generatePublic(keySpec);
    }
}

Example: Verifying the JWT in Go

package main

import (
	"crypto/rsa"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"math/big"
	"net/http"

	"github.com/golang-jwt/jwt"
)

const jwksURL = "https://assets.sesamy.com/vault-jwks.json"

type JWKS struct {
	N string `json:"n"`
	E string `json:"e"`
}

func verifyJWT(tokenString string) (*jwt.Token, error) {
	jwks, err := getJWKS()
	if err != nil {
		return nil, err
	}

	key, err := getRSAPublicKey(jwks)
	if err != nil {
		return nil, err
	}

	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return key, nil
	})

	if err != nil {
		return nil, err
	}

	if !token.Valid {
		return nil, fmt.Errorf("invalid token")
	}

	return token, nil
}

func getJWKS() (*JWKS, error) {
	resp, err := http.Get(jwksURL)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var jwks JWKS
	err = json.NewDecoder(resp.Body).Decode(&jwks)
	if err != nil {
		return nil, err
	}

	return &jwks, nil
}

func getRSAPublicKey(jwks *JWKS) (*rsa.PublicKey, error) {
	nBytes, err := base64.RawURLEncoding.DecodeString(jwks.N)
	if err != nil {
		return nil, err
	}

	eBytes, err := base64.RawURLEncoding.DecodeString(jwks.E)
	if err != nil {
		return nil, err
	}

	n := new(big.Int).SetBytes(nBytes)
	e := new(big.Int).SetBytes(eBytes)

	return &rsa.PublicKey{
		N: n,
		E: int(e.Int64()),
	}, nil
}

Serving Locked Content via API

To fetch locked content from the server, set the lock-mode attribute of the sesamy-content-container web component to "jwt". By default, it fetches the locked content from the article URL using the JWT. You can specify a custom access-url property on the content-container to use a different URL for fetching the locked content:

<sesamy-content-container
    lock-mode="jwt"
    access-url="https://example.com/api/access/test-article"
>

The JWT will be passed in the Authorization header as a Bearer token:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Cross-Origin Resource Sharing (CORS)

When serving content from an API endpoint on a separate domain, the API needs to handle preflight requests to enable Cross-Origin Resource Sharing (CORS). Ensure that your API server is configured to respond to OPTIONS requests and includes the necessary CORS headers in its responses.

  • Url of the content
  • Optional information, such as the Publisher's Content ID
  • Expiration timestamp
  • Signature of the previous parts of the url

This is a sample of a signed link: https://test.example.com/test-article?se=TIMESTAMP&token=ey...

The https://test.example.com/test-article is the url of the purchased article, se=1656098737770 is the expiration timestamp of the url and token=... is the token.

If a publisher provided a Publisher Content ID in the metatags this ID will also be passed as an sp querystring parameter in the signed link: https://test.example.com/test-article?sp=PUBLISHER_CONTENT_ID&se=TIMESTAMP&token=ey...

Validation of the signature

The urls are signed with an RSA256 asymmetric key that can be verified with the public Sesamy key. The public key is published in a jwks-format here: https://assets.sesamy.com/vault-jwks.json

This is an example of how to verify the signed url in node-js with typescript:

import { verify, createPublicKey, KeyObject } from 'crypto';
import jwkToPem from 'jwk-to-pem';

async function fetchPublicKey() {
const response = await fetch('https://assets.sesamy.com/vault-jwks.json');

    if (!response.ok) {
        throw new Error('Failed to fetch public key');
    }

    return response.json();

}

async function getPublicKey() {
const jwks = await fetchPublicKey();

    const pemKey = jwkToPem(jwks);
    return createPublicKey(pemKey);

}

async function verifySignature(signedUrl: string) {
const publicKey = await getPublicKey();

    const [url, signature] = signedUrl.split('&ss=');
    if (!verify('RSA-SHA256', Buffer.from(url), publicKey, Buffer.from(signature, 'base64'))) {
        throw new Error('Signature not valid');
    }

}

This is an example of how to verify the signed url in php:

<?php

require __DIR__ . '/vendor/autoload.php';

use Jose\Component\Core\JWK;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Signature\Algorithm\RS256;

function verify_signature($url)
{
    $url_to_sign = explode('&ss=', $url)[0];
    $signature = explode('&ss=', $url)[1];

    $parsed_url = parse_url($url);
    parse_str($parsed_url['query'], $parsed_query);

    $expire = $parsed_query['se'];
    if ($expire  < time()) {
        return new Exception('Link is expired');
    }

    $algorithm_manager = new AlgorithmManager([
        new RS256()
    ]);

    $rs256 = $algorithm_manager->get('RS256');
    $json = '{
        "kty": "RSA",
        "n": "sFATY4fG4n822Zn8bQpszyF9navI_O5lwEg12fEHJGq69EKEfX1xFBXYNj8xEg6ROe4Zl-ssG1Co3Mb3M8zSE9shGSNmMB86oqPOZ9RZTYmiGg_Uh6FqGuP_-SzUC6k8gGVzoo1gn06dqv_S06cT7GW616T57DVHS280FPZ1JLmu88VaBhY_8kgCAqEWgdveLYYWzJhuiTcocCUVRbIElKwWzLbze4BpUQtLQmW5QL-zwYOYXlbamnN-2VP7ZshTqRZEG-LCwI9DEWVUZsdSBdDtG0xH8aTf1BxCAcdcFdPJM2lNa9DmQnNlcB420jL3vKu2mFxxE1Zn_5PIu19pmQ",
        "e": "AQAB"
        }';
    $jwk = JWK::createFromJson($json);
    return $rs256->verify($jwk, $url_to_sign, base64_decode($signature));
}

Signed link with pass

If an article is part of a pass (for instance a subscription) the signed link will be for the pass rather than the article. This way a signed link can be reused for all articles within a subscription until the link has expired. In this case the backend needs to validate that the article is part of the pass before serving the content.

The signed link is passed to the backend as a pass query string (the signature is shortened for brevity): https://test.example.com/test-article?pass=https%3A%2F%2Ftest.example.com%2Fsubscription%3Fse%3D1656098737770%26si%3D113%26ss%3DPTKUZ...

Serving locked content via API

By setting the lock mode of the content-container web component to signedUrl the content is fetched from the server. By default it fetches the locked content from the article url using the signed url, but by specifying the access-url property on the content-container it will use this url to fetch the locked content and pass the signed url in the x-sesamy-signed-url header instead:

<sesamy-content-container
    lock-mode="signedUrl"
    access-url="https://example.com/api/access/test-article"
>

Cross-Origin Resource Sharing (CORS)

Cross-Origin Resource Sharing (CORS) is a browser mechanism which enables controlled access to resources located outside of the browser domain. When serving content from an API endpoint on a separate domain the API needs to handle preflight requests.

Did this answer your question?
😞
😐
🤩