diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml
index b2fb9d4..495d862 100644
--- a/.github/workflows/docker-build-test.yml
+++ b/.github/workflows/docker-build-test.yml
@@ -12,4 +12,4 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Run test via makefile
- run: make test
\ No newline at end of file
+ run: pip install -e . && make test
\ No newline at end of file
diff --git a/README.org b/README.org
index ba89c38..78db994 100644
--- a/README.org
+++ b/README.org
@@ -1,8 +1,12 @@
+#+OPTIONS: ^:{}
+
* Introduction
The =ox_jwt= repository provides some simple tools for working with JSON Web
-Tokens (JWTs).
+Tokens (JWTs) as well as illustrating some use cases with =nginx= and =python=.
+
+** nginx
For example, the =nginx= sub-directory contains a fully working
minimal example of how you can setup NGINX to only allow access to
@@ -21,9 +25,26 @@ This is useful because:
3. All of the encryption/decryption used is open source, verifiable,
and modifiable by you.
+** python
+
+The slides in [[file:docs/slides.html][docs/slides.html]] provide a detailed presentation of what
+JWTs are and how to use them in python.
+
+You can use JWTs in python with or without nginx. For example, you
+could have nginx do validation of the JWT and then provide the decoded
+JWT as a header to further python code. Alternatively, you can do JWT
+encoding and decoding purely in python.
+
+If you =pip install ox_jwt=, you can then run the command
+=test_ox_jwt= to do a simple local test of encoding JWTs and decoding
+them with a flask server. See the python files in [[file:src/ox_jwt/][src/ox_jwt/]] to see
+how the python example works.
+
* Quickstart and Demo
-For a simple test of the system, you can do something like
+** nginx
+
+For a simple test of the nginx system, you can do something like
#+BEGIN_SRC sh
make test
#+END_SRC
@@ -43,9 +64,34 @@ at the command line to do the following:
6. Uses =curl= to verify that you can only access the protected
location in your NGINX server if you have the appropriate JWT.
+** python
+
+For a test of the python implementation, you can do
+#+BEGIN_SRC sh
+pip install ox_jwt
+#+END_SRC
+and then run the =test_ox_jwt= command.
+
+Alternatively, you can clone the GitHub repository via something like
+#+BEGIN_SRC sh
+git clone https://github.com/aocks/ox_jwt.git
+#+END_SRC
+and then build a virtual env and install dependencies via
+#+BEGIN_SRC sh
+cd ox_jwt
+python3 -m venv venv_ox_jwt
+source venv_ox_jwt/bin/activate
+pip install -e .
+#+END_SRC
+and then run the tests/demo via:
+#+BEGIN_SRC sh
+py.test src/ox_jwt
+#+END_SRC
+
+
* FAQ
-** What are some alternatives to =ox_jwt=?
+** What are some alternatives to =ox_jwt= for nginx?
- SSL client certificates
- but [[https://security.stackexchange.com/questions/198837/why-is-client-certificate-authentication-not-more-common][SSL client certificates are difficult to setup and maintain]]
@@ -59,7 +105,7 @@ at the command line to do the following:
- [[https://www.npmjs.com/package/jwt-simple][jwt-simple]] for javascript (used by this project as well)
- [[https://pyjwt.readthedocs.io/en/stable/][pyjwt]] for general python JWT tools
-** Aren't there some other easy alternatives?
+** Aren't there some other easy alternatives for nginx?
Not that I am aware of. Most other approaches to JWT validation in
your *APPLICATION* server instead of your *NGINX* server or proxy or
@@ -67,9 +113,9 @@ require a commercial (i.e., non-open-source) server.
** Why do JWT validation in NGINX?
-Plenty of applications do JWT validation and decoding themselves. That
-is a fine and useful thing to do and can also be combined with
-validating the JWT in NGINX as well.
+Plenty of applications do JWT validation and decoding themselves as
+shown in the python examples. That is a fine and useful thing to do
+and can also be combined with validating the JWT in NGINX as well.
A few reasons why you might want to do JWT validation in the web
server instead of or in addition to the application include:
diff --git a/docs/images/jwt-auth-vs-app-auth-response.jpg b/docs/images/jwt-auth-vs-app-auth-response.jpg
new file mode 100644
index 0000000..6dae253
Binary files /dev/null and b/docs/images/jwt-auth-vs-app-auth-response.jpg differ
diff --git a/docs/images/jwt-auth-vs-app-auth.jpg b/docs/images/jwt-auth-vs-app-auth.jpg
new file mode 100644
index 0000000..e9847b3
Binary files /dev/null and b/docs/images/jwt-auth-vs-app-auth.jpg differ
diff --git a/docs/images/jwt-auth-vs-app-request-app.jpg b/docs/images/jwt-auth-vs-app-request-app.jpg
new file mode 100644
index 0000000..79ab6d2
Binary files /dev/null and b/docs/images/jwt-auth-vs-app-request-app.jpg differ
diff --git a/docs/images/jwt-auth-vs-app-separate.jpg b/docs/images/jwt-auth-vs-app-separate.jpg
new file mode 100644
index 0000000..a7662ad
Binary files /dev/null and b/docs/images/jwt-auth-vs-app-separate.jpg differ
diff --git a/docs/images/jwt-auth-vs-app-start.jpg b/docs/images/jwt-auth-vs-app-start.jpg
new file mode 100644
index 0000000..0f2b437
Binary files /dev/null and b/docs/images/jwt-auth-vs-app-start.jpg differ
diff --git a/docs/images/jwt-get-access.jpg b/docs/images/jwt-get-access.jpg
new file mode 100644
index 0000000..ee700e8
Binary files /dev/null and b/docs/images/jwt-get-access.jpg differ
diff --git a/docs/images/jwt-get-refresh.jpg b/docs/images/jwt-get-refresh.jpg
new file mode 100644
index 0000000..9c66522
Binary files /dev/null and b/docs/images/jwt-get-refresh.jpg differ
diff --git a/docs/images/jwt-revoke.jpg b/docs/images/jwt-revoke.jpg
new file mode 100644
index 0000000..18193fe
Binary files /dev/null and b/docs/images/jwt-revoke.jpg differ
diff --git a/docs/images/jwt-use-access.jpg b/docs/images/jwt-use-access.jpg
new file mode 100644
index 0000000..3a86425
Binary files /dev/null and b/docs/images/jwt-use-access.jpg differ
diff --git a/docs/images/nginx-example.jpg b/docs/images/nginx-example.jpg
new file mode 100644
index 0000000..bb14b3a
Binary files /dev/null and b/docs/images/nginx-example.jpg differ
diff --git a/docs/slides.html b/docs/slides.html
new file mode 100644
index 0000000..fc44200
--- /dev/null
+++ b/docs/slides.html
@@ -0,0 +1,876 @@
+
+
+
+
+Tips, Tricks, and Reasons for JSON Web Tokens (JWTs)
+
+
+
+
+
+
+
+
+
+
+
+
+
Tips, Tricks, and Reasons for JSON Web Tokens (JWTs)
+Emin Martinian
+
+
+
+
+
+JWT: JSON Web Token
+
+Used for authentication/authorization such as:
+
+
+
+
+
+
+
+
+
+
+
+
+Why JWTs?
+
+Imagine app with many features + servers + engineers:
+
+
+
+Load balance, payments, profiles, PII, DB
+Local/remote/international workers + consultants
+How to manage security?
+
+Can't give everyone access to sensitive info
+
+
+
+
+
+
+
+
+
+
+JWT Architecture
+
+
+Auth server manages passwords, takes credit cards, etc.
+Must be secure and in sync; hard to load balance
+Cannot let just any employee have access
+
+
+
+
+
+
+Separate authentication from validation/application:
+
+
+Authentication requires secret keys (high security)
+Validation can use public key (less security)
+Easier to manage secrets, keys, load, sync, etc.
+
+
+
+
+
+
+
+
+
+
+
+JWT: Authentication Request
+
+
+
+
+Client authenticates to server:
+
+
+
+Auth server must be secure
+Payment or Login with username/password/MFA
+May require database check, locks, other slow ops
+
+
+
+
+
+
+
+
+
+
+
+
+
+JWT: Authentication Response
+
+Server responds with JWT:
+
+
+
+header describing JWT
+claims describing info/rights (iat, nbf, exp, etc.)
+signature from Auth Server
+
+
+
+
+
+
+
+
+
+
+
+JWT: Application Request
+
+
+Distributed Trust
+App Server(s) can be load balanced or serverless
+App Server(s) can be maintained with lower security requirements
+
+
+
+
+
+
+
+Client sends JWT to App Server:
+
+
+
+App Server validates JWT with public key
+No DB/state/sync/update; can be serverless
+Checks JWT for rights + provides service
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Separate Auth From Validation
+
+Auth Server has secrets ; needs security + maintenance
+
+
+
+App Server(s) needs public keys; low security
+Easy to deploy App Server(s); e.g., serverless
+Lower security for App Server(s), logs, debug, etc.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+What do JWTs look like?
+
+Base64 encoded header.payload.signature:
+
+
+
+
+
HEADER: { "alg" : "EdDSA" , "typ" : "JWT" }
+
+
+
+
+
+
PAYLOAD: {"sub" : "a" , "name" : "arbitrary data" , "iat" : 1 }
+
+
+
+
+
+
SIGNATURE: SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-L
+ L5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+
+
+
+
+
+
+
ENCODED JWT: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9
+ .eyJzdWIiOiJhIiwibmFtZSI6ImIiLCJpYXQiOjF9
+ .SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-L
+ L5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+
+
+
+
+
+Signed using EdDSA with secret key:
+
+
+
+
+
MC4CAQAwBQYDK2VwBCIEIC+D6rD2YbXtV0ccR3smoR0ynhVuyyqvplFLbQWDdAtn
+
+
+
+
+
+
+Use https://jwt.io/#debugger-io to verify/validate/decode
+You can put arbitrary data in the payload:
+
+indicate username, roles, rights, restrictions, payments
+
+
+
+
+
+
+
+
+
+Secret Key
+
+
+We use EdDSA because it is secure, short, and deterministic.
+You could use ESA256, but beware that uses a nonce and is non-deterministic.
+
+
+
+
+
+
+
+
import base64, jwt # pip install 'pyjwt[crypto]'
+from cryptography.hazmat.primitives.asymmetric import ed25519
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.backends import default_backend
+
+secret_key = base64.b64encode( # How to generate new key
+ ed25519.Ed25519PrivateKey.generate().private_bytes(
+ encoding=serialization.Encoding.DER,
+ format =serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption())
+).decode('utf8' )
+
+secret_key = ( # We hard code secret key so you can verify results
+ 'MC4CAQAwBQYDK2VwBCIEIC+D6rD2YbXtV0ccR3smoR0ynhVuyyqvplFLbQWDdAtn'
+)
+
+
+
+
+
+
+Public Key
+
+
+
sk = serialization.load_der_private_key( # de-serialize encoded key
+ base64.b64decode(secret_key),backend=default_backend(),
+ password=None )
+
+pk = sk.public_key()
+public_key = pk.public_bytes( # serialize
+ encoding=serialization.Encoding.PEM,
+ format =serialization.PublicFormat.SubjectPublicKeyInfo
+).decode('utf8' )
+
+print (public_key)
+
+
+
+
+
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAUVLjZWAVK5ZE1ewI5QBdr0Nig1Qkx3kl5zHIADvw0M8=
+-----END PUBLIC KEY-----
+
+
+
+
+
+
+Encoding Example JWT
+
+
+
import textwrap # just for display
+
+example_jwt = jwt.encode(
+ headers={'typ' :'JWT' , 'alg' :'EdDSA' },
+ payload={'sub' : 'a' , 'name' : 'b' , 'iat' : 1},
+ key=sk) # this is the JWT that would be used
+print (textwrap.indent(textwrap.fill( # format for
+ ' \n .' .join(example_jwt.split('.' )), # nice display
+ width=44, replace_whitespace=False ), prefix=' ' ))
+
+
+
+
+Encoded JWT:
+
+
+eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9
+.eyJzdWIiOiJhIiwibmFtZSI6ImIiLCJpYXQiOjF9
+.SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-
+LL5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+
+
+
+
+Decoding Example JWT
+
+
+
decoded_jwt = jwt.decode(example_jwt, algorithms=['EdDSA' ], key=pk)
+print (decoded_jwt)
+
+
+
+
+Decoded Payload from JWT:
+
+
+{'sub': 'a', 'name': 'b', 'iat': 1}
+
+
+
+
+
+
+Main JWT Fields
+
+sub : Subject (username, email, etc.)
+iat : Issued at (useful for checking freshness)
+exp : Expiry (useful for managing life-cycle )
+nbf : Not before (useful for managing life-cycle )
+
+
+
+
+
+
+
+Python/Flask Example
+
+Easy to verify/decode using libraries (e.g., pyjwt) and compose
+checks using decorators:
+
+
+
+
+
@app.route ('/support/urgent' )
+@requires_jwt # validates JWT
+@jwt_claims (['paid_support' ]) # ensures token is for premium user
+@jwt_iat (datetime.timedelta(hours=24)) # ensure recent token
+def support_urgent ():
+ ... # process ending support request
+
+
+
+
+
+Starting Flask
+
+
+
import os
+import sys
+import subprocess
+
+os.chdir(os.path.expanduser('~/code/ox_jwt/src/ox_jwt' ))
+my_env = os.environ.copy()
+my_env ['FLASK_JWT_KEY' ] = public_key.split(' \n ' )[1]
+my_env ['FLASK_JWT_ALGS' ] = 'EdDSA,ES256'
+proc = subprocess.Popen([sys.executable, 'app.py' ], env=my_env)
+# Use proc.kill() to shutdown server
+
+
+
+
+
+
+
+
+
+Example of @requires_jwt
+
+
+
def requires_jwt (func):
+ @wraps (func)
+ def decorated (*args, **kwargs):
+ token = request.headers.get("Authorization" ).split(" " )[1]
+ if not token:
+ return 'missing token' , 401 # if no token return error
+ try :
+ g.decoded_jwt = jwt.decode(
+ token, algorithms=['EdDSA' ],
+ key=current_app.config['JWT_KEY' ]) # public key
+ return func(*args, **kwargs)
+ except Exception as problem:
+ return f' {problem=}' , 401 # return 401 or other error code
+ return decorated
+
+
+
+
+
+Ensure Valid Token
+
+
+
import requests
+
+req = requests.get('http://127.0.0.1:5000/hello' , headers={
+ 'Authorization' : f'Bearer {example_jwt}mybad' }) # bad token
+print (f'Bad token response: \n code: {req.status_code}\n '
+ f' text: {req.text}\n ' )
+
+req = requests.get('http://127.0.0.1:5000/hello' , headers={
+ 'Authorization' : f'Bearer {example_jwt}' })
+print (f'Good token response: \n code: {req.status_code}\n '
+ f' text: {req.text}\n ' )
+
+
+
+
+Bad token response:
+ code: 401
+ text: problem=InvalidSignatureError('Signature verification failed')
+
+Good token response:
+ code: 200
+ text: Hello World!
+
+
+
+
+
+
+
+
+Example of @jwt_claims
+
+
+
def jwt_claims (claims_list: typing.Sequence[str ]):
+ def make_decorator (func):
+ @wraps (func)
+ def decorated (*args, **kwargs):
+ missing = [c for c in claims_list
+ if not g.decoded_jwt.get(c)]
+ if missing:
+ return f'Missing claims: {missing}' , 401
+ return func(*args, **kwargs)
+ return decorated
+ return make_decorator
+
+
+
+
+
+Ensure Claims (Bad Token)
+
+
+
import datetime, requests
+
+req = requests.get('http://127.0.0.1:5000/support/urgent' , headers={
+ 'Authorization' : f'Bearer {example_jwt}' }) # bad token
+
+print (f'Bad token response: \n code: {req.status_code}\n '
+ f' text: {req.text}\n ' )
+
+
+
+
+Bad token response:
+ code: 401
+ text: Missing claims: ['premium_user']
+
+
+
+
+Ensure Claims (Bad Claims)
+
+
+
+premium_jwt = jwt.encode(headers={'typ' :'JWT' , 'alg' :'EdDSA' },
+ payload={'sub' : 'a' , 'premium_user' : 'b' , 'iat' : 1}, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/support/urgent' , headers={
+ 'Authorization' : f'Bearer {premium_jwt}' })
+
+print (f'Premium token response: \n code: {req.status_code}\n '
+ f' text: {req.text}\n ' )
+
+
+
+
+Premium token response:
+ code: 401
+ text: Token age 20193 days, 17:37:05.670865 not within 0:00:30
+
+
+
+
+Ensure Claims (Success)
+
+
+
now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+recent_premium_jwt = jwt.encode(headers={'typ' :'JWT' , 'alg' :'EdDSA' },
+ payload={'sub' : 'a' , 'premium_user' : 'b' , 'iat' : int (now)}, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/support/urgent' , headers={
+ 'Authorization' : f'Bearer {recent_premium_jwt}' })
+
+print (f'Recent premium token response: \n code: {req.status_code}\n '
+ f' text: {req.text}\n ' )
+
+
+
+
+Recent premium token response:
+ code: 200
+ text: processing support request for user b
+
+
+
+
+
+
+Example Use Case: Proxy
+
+Auth Server grants JWT letting Alice to act for Bob
+claims: {"sub": "Alice", "proxy": "Bob"}
+Alice sends request combining to act for Bob
+
+
+
+
+
+
+
+
+Example Use Case: Proxy
+
+Auth Server grants JWT letting Alice to act for Bob
+claims: {"sub": "Alice", "proxy": "Bob"}
+Alice sends request combining to act for Bob
+
+
+
+
+
+
@APP.route ("/issue" )
+@requires_jwt
+def issue ():
+ "Example route to create an issue."
+ user = g.decoded_jwt.get('proxy' , g.decoded_jwt.get('sub' ))
+ msg = f'Created issue assigned to {user}.'
+ # ... Create the actual issue here
+
+
+
+ return msg
+
+
+
+
+
+
+
+Example Use Case: Proxy
+
+Auth Server grants JWT letting Alice to act for Bob
+claims: {"sub": "Alice", "proxy": "Bob"}
+Alice sends request combining to act for Bob
+
+
+
+
+
+
@APP.route ("/issue" )
+@requires_jwt
+def issue ():
+ "Example route to create an issue."
+ user = g.decoded_jwt.get('proxy' , g.decoded_jwt.get('sub' ))
+ msg = f'Created issue assigned to {user}.'
+ # ... Create the actual issue here
+ real_user = g.decoded_jwt['sub' ]
+ if real_user != user:
+ msg += f' \n {real_user} acted on behalf of {user}'
+ return msg
+
+
+
+
+
+Python demo
+
+
+
+now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+proxy_example_jwt = jwt.encode(headers={'typ' :'JWT' , 'alg' :'EdDSA' },
+ payload={'sub' : 'Alice' , 'proxy' : 'Bob' }, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/issue' , headers={
+ 'Authorization' : f'Bearer {proxy_example_jwt}' })
+print (req.text)
+
+
+
+
+
+127.0.0.1 - - [24/Apr/2025 13:25:28] "GET /issue HTTP/1.1" 200 -
+Created issue assigned to Bob.
+Alice acted on behalf of Bob
+
+
+
+
+
+
+
+
+
+Anti-Patterns
+
+Beware using header fields to check signature
+
+don't trust alg field or limit possibilities
+
+e.g., algorithms=['EdDSA']
+
+
+be careful with kid, jku, jwk, etc.
+
+
+Don't simulate sessions with JWTs
+Token revocation issue: access/refresh tokens
+
+
+
+
+
+
+
+
+Revocation via Access/Refresh
+
+Problem: Can't cancel or logout a JWT
+Solution: Refresh/Access token
+
+"refresh token" with long expiry
+used to get access token w/o credential check
+"access token" with short expiry
+can be used to access services
+
+
+
+
+
+
+
+
+On security events (role changes, credential changes, hacks), auth
+server will invalidate refresh token + require new credential check.
+
+
+
+
+
+
+
+
+
+Get Refresh Token
+
+
+
+
+
+
+
+
+Get Access Token
+
+
+
+
+
+
+
+
+Use Access Token
+
+
+
+
+
+
+
+
+
+Separate validation from parsing
+
+
+We can go one step beyond separating authentication from validation
+and separate validation from parsing.
+
+
+
+aside: NGINX+JWTs can protect stand-alone sites
+
+
+
+
+
+
+
+
+
+
+
+Summary and next steps
+
+
+If you are writing a small application, you can quickly and easily put
+together a secure system using various JWT libraries.
+
+
+
+If you are doing a full enterprise authentication system, you may want
+to go with an existing platform. Many of those use JWTs under the hood
+so it's still useful to have a high level understanding of the basic diea.
+
+
+
+
+
+Distributed trust can enable many use cases
+JWTs = secure, efficient, standardized auth tool
+Python decorators = nice way to validate claims
+Libraries:
+
+Platforms:
+
+Slides/examples: https://github.com/aocks/ox_jwt/
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/slides.org b/docs/slides.org
new file mode 100644
index 0000000..a4d5c00
--- /dev/null
+++ b/docs/slides.org
@@ -0,0 +1,920 @@
+
+
+#+COMMENT: using timestamp:nil suppresses "created at" in title
+#+COMMENT: using num:nil prevents slide titles being numbered
+#+OPTIONS: timestamp:nil num:nil toc:nil ^:{}
+
+#+REVEAL_REVEAL_JS_VERSION: 4
+#+REVEAL_ROOT: https://cdn.jsdelivr.net/npm/reveal.js@4.0.0/
+#+REVEAL_PLUGINS: (notes)
+#+REVEAL_THEME: solarized
+#+REVEAL_INIT_OPTIONS: fragments:true, transition:'fade'
+
+
+#+COMMENT: Use `s` to engage speaker mode
+
+#+TITLE: Tips, Tricks, and Reasons for JSON Web Tokens (JWTs)
+#+AUTHOR: Emin Martinian
+
+
+* Basic Setup :noexport:
+
+#+BEGIN_SRC emacs-lisp :exports none
+(require 'ox-reveal)
+
+;; Make sure to use version 4.0 and set REVEAL_REVEAL_JS_VERSION below
+(setq org-reveal-root "https://cdn.jsdelivr.net/npm/reveal.js@4.0.0/")
+(setq org-reveal-plugins '(notes))
+(setq org-export-babel-evaluate 't)
+#+END_SRC
+
+#+RESULTS:
+: t
+
+
+* Code Fragment Example :noexport:
+
+#+BEGIN_SRC python
+print("This appears immediately")
+#+END_SRC
+
+#+ATTR_REVEAL: :frag appear
+#+BEGIN_SRC python
+print("This appears after clicking")
+#+END_SRC
+
+
+* JWT: JSON Web Token
+
+Used for authentication/authorization such as:
+
+
+- front-end client to access back-end or API server
+- compact, [[https://datatracker.ietf.org/doc/html/rfc7519][standardized]], secured, customizable
+- "state-less" alternative to cookies/sessions
+- slides/examples: https://github.com/aocks/ox_jwt
+
+
+#+BEGIN_NOTES
+- standard: [[https://datatracker.ietf.org/doc/html/rfc7519][RFC 7519]]
+#+END_NOTES
+
+* Why JWTs?
+
+Imagine app with many features + servers + engineers:
+
+#+ATTR_REVEAL: :frag (appear appear appear)
+- Load balance, payments, profiles, PII, DB
+- Local/remote/international workers + consultants
+- How to manage security?
+ - Can't give everyone access to sensitive info
+
+
+* JWT Architecture
+#+BEGIN_NOTES
+- Auth server manages passwords, takes credit cards, etc.
+- Must be secure and in sync; hard to load balance
+- Cannot let just any employee have access
+#+END_NOTES
+
+Separate authentication from validation/application:
+- Authentication requires secret keys (high security)
+- Validation can use public key (less security)
+- Easier to manage secrets, keys, load, sync, etc.
+
+#+name: jwt-auth-vs-app-start
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-auth-vs-app-start.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="JWT", constraint=false, splines=ortho, style=invis];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth\nor credit card)", constraint=false, splines=ortho, style=invis];
+ Client -> AppServer [label="Request Service\nusing JWT", constraint=false, splines=ortho,style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+
+#+end_src
+
+#+RESULTS: jwt-auth-vs-app-start
+[[file:images/jwt-auth-vs-app-start.jpg]]
+
+
+* JWT: Authentication Request
+
+#+BEGIN_NOTES
+Managing the authentication server is more complicated.
+- Can't allow just anyone to access/maintain/deploy (has secrets)
+- Must maintain state (e.g., current user password) so hard to load balance
+#+END_NOTES
+
+
+Client authenticates to server:
+
+#+ATTR_REVEAL: :frag (appear appear)
+- Auth server must be secure
+- Payment or Login with username/password/MFA
+- May require database check, locks, other slow ops
+
+
+
+#+name: jwt-auth-vs-app-auth
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-auth-vs-app-auth.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="JWT", constraint=false, splines=ortho, style=invis];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth or\ncredit card)", constraint=false, splines=ortho];
+ Client -> AppServer [label="Request Service\nusing JWT", constraint=false, splines=ortho,style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+
+#+end_src
+
+#+RESULTS: jwt-auth-vs-app-auth
+[[file:images/jwt-auth-vs-app-auth.jpg]]
+
+
+* JWT: Authentication Response
+
+Server responds with JWT:
+
+#+ATTR_REVEAL: :frag (appear appear)
+- header describing JWT
+- claims describing info/rights (iat, nbf, exp, etc.)
+- signature from Auth Server
+
+#+name: jwt-auth-vs-app-auth-response
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-auth-vs-app-auth-response.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="JWT", constraint=false, splines=ortho];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth)", constraint=false, splines=ortho,style=invis];
+ Client -> AppServer [label="Request Service\nusing JWT", constraint=false, splines=ortho,style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+
+#+end_src
+
+#+RESULTS: jwt-auth-vs-app-auth-response
+[[file:images/jwt-auth-vs-app-auth-response.jpg]]
+
+
+* JWT: Application Request
+
+#+BEGIN_NOTES
+- Distributed Trust
+- App Server(s) can be load balanced or serverless
+- App Server(s) can be maintained with lower security requirements
+#+END_NOTES
+
+
+Client sends JWT to App Server:
+
+#+ATTR_REVEAL: :frag (appear appear)
+- App Server validates JWT with public key
+- No DB/state/sync/update; can be serverless
+- Checks JWT for rights + provides service
+
+
+
+
+#+name: jwt-auth-vs-app-request-app
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-auth-vs-app-request-app.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="JWT", constraint=false, splines=ortho,style=invis];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth)", constraint=false, splines=ortho,style=invis];
+ Client -> AppServer [label="Send JWT to\nRequest Service", constraint=false, splines=ortho];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+
+#+end_src
+
+#+RESULTS: jwt-auth-vs-app-request-app
+[[file:images/jwt-auth-vs-app-request-app.jpg]]
+
+
+
+
+
+* Separate Auth From Validation
+
+Auth Server has **secrets**; needs **security** + maintenance
+
+#+ATTR_REVEAL: :frag (appear appear)
+- App Server(s) needs public keys; low security
+- Easy to deploy App Server(s); e.g., serverless
+- Lower security for App Server(s), logs, debug, etc.
+
+#+name: jwt-auth-vs-app-separate
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-auth-vs-app-separate.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="JWT", constraint=false, splines=ortho,style=invis];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth)", constraint=false, splines=ortho,style=invis];
+ Client -> AppServer [label="Send JWT to\nRequest Service", constraint=false, splines=ortho, style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+
+#+end_src
+
+#+RESULTS: jwt-auth-vs-app-separate
+[[file:images/jwt-auth-vs-app-separate.jpg]]
+
+
+
+
+
+
+* What do JWTs look like?
+
+Base64 encoded header.payload.signature:
+
+#+ATTR_REVEAL: :frag appear :frag_idx 1
+#+BEGIN_src shell
+HEADER: { "alg": "EdDSA", "typ": "JWT" }
+#+END_src
+
+#+ATTR_REVEAL: :frag appear :frag_idx 2
+#+BEGIN_src shell
+PAYLOAD: {"sub": "a", "name": "arbitrary data", "iat": 1 }
+#+END_src
+
+#+ATTR_REVEAL: :frag appear :frag_idx 3
+#+BEGIN_src shell
+SIGNATURE: SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-L
+ L5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+
+#+END_src
+
+#+ATTR_REVEAL: :frag appear :frag_idx 4
+#+BEGIN_src shell
+ENCODED JWT: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9
+ .eyJzdWIiOiJhIiwibmFtZSI6ImIiLCJpYXQiOjF9
+ .SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-L
+ L5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+#+END_src
+
+
+#+ATTR_REVEAL: :frag appear :frag_idx 5
+Signed using EdDSA with secret key:
+
+#+ATTR_REVEAL: :frag appear :frag_idx 5
+#+BEGIN_src python
+MC4CAQAwBQYDK2VwBCIEIC+D6rD2YbXtV0ccR3smoR0ynhVuyyqvplFLbQWDdAtn
+#+END_src
+
+
+#+BEGIN_NOTES
+- Use https://jwt.io/#debugger-io to verify/validate/decode
+- You can put arbitrary data in the payload:
+ - indicate username, roles, rights, restrictions, payments
+#+END_NOTES
+
+** Secret Key
+
+#+BEGIN_NOTES
+- We use EdDSA because it is secure, short, and deterministic.
+- You could use ESA256, but beware that uses a nonce and is non-deterministic.
+#+END_NOTES
+
+#+name: create-keys
+#+BEGIN_SRC python :session jwt_example :exports code :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+import base64, jwt # pip install 'pyjwt[crypto]'
+from cryptography.hazmat.primitives.asymmetric import ed25519
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.backends import default_backend
+
+secret_key = base64.b64encode( # How to generate new key
+ ed25519.Ed25519PrivateKey.generate().private_bytes(
+ encoding=serialization.Encoding.DER,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption())
+).decode('utf8')
+
+secret_key = ( # We hard code secret key so you can verify results
+ 'MC4CAQAwBQYDK2VwBCIEIC+D6rD2YbXtV0ccR3smoR0ynhVuyyqvplFLbQWDdAtn'
+)
+#+END_SRC
+
+#+RESULTS: create-keys
+
+
+** Public Key
+
+#+name: get-public-key
+#+BEGIN_SRC python :session jwt_example :exports both :results output :eval never-export
+sk = serialization.load_der_private_key( # de-serialize encoded key
+ base64.b64decode(secret_key),backend=default_backend(),
+ password=None)
+
+pk = sk.public_key()
+public_key = pk.public_bytes( # serialize
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
+).decode('utf8')
+
+print(public_key)
+#+END_SRC
+
+
+#+RESULTS: get-public-key
+: -----BEGIN PUBLIC KEY-----
+: MCowBQYDK2VwAyEAUVLjZWAVK5ZE1ewI5QBdr0Nig1Qkx3kl5zHIADvw0M8=
+: -----END PUBLIC KEY-----
+
+
+
+** Encoding Example JWT
+
+#+NAME: encoded-jwt
+#+BEGIN_SRC python :session jwt_example :exports both :results output :eval never-export
+import textwrap # just for display
+
+example_jwt = jwt.encode(
+ headers={'typ':'JWT', 'alg':'EdDSA'},
+ payload={'sub': 'a', 'name': 'b', 'iat': 1},
+ key=sk) # this is the JWT that would be used
+print(textwrap.indent(textwrap.fill( # format for
+ '\n.'.join(example_jwt.split('.')), # nice display
+ width=44, replace_whitespace=False), prefix=' '))
+#+END_SRC
+
+Encoded JWT:
+#+RESULTS: encoded-jwt
+: eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9
+: .eyJzdWIiOiJhIiwibmFtZSI6ImIiLCJpYXQiOjF9
+: .SU6aXJ0YbH7Vg1jROpQfvnhn98Rt9zBeS7-c5O9jH-
+: LL5mQqMMFq61eZjf0tLLqExm-dckRUNa3-qT7R2SKmCw
+
+** Decoding Example JWT
+
+#+NAME: decoded-jwt
+#+BEGIN_SRC python :session jwt_example :exports both :results output :eval never-export
+decoded_jwt = jwt.decode(example_jwt, algorithms=['EdDSA'], key=pk)
+print(decoded_jwt)
+#+END_SRC
+
+Decoded Payload from JWT:
+#+RESULTS: decoded-jwt
+: {'sub': 'a', 'name': 'b', 'iat': 1}
+
+* Main JWT Fields
+
+- *sub*: Subject (username, email, etc.)
+- *iat*: Issued at (useful for checking freshness)
+- *exp*: Expiry (useful for managing life-cycle )
+- *nbf*: Not before (useful for managing life-cycle )
+
+* Python/Flask Example
+
+Easy to verify/decode using libraries (e.g., =pyjwt=) and compose
+checks using decorators:
+
+#+BEGIN_SRC python
+@app.route('/support/urgent')
+@requires_jwt # validates JWT
+@jwt_claims(['paid_support']) # ensures token is for premium user
+@jwt_iat(datetime.timedelta(hours=24)) # ensure recent token
+def support_urgent():
+ ... # process ending support request
+#+END_SRC
+
+** Starting Flask
+
+#+name: start-flask
+#+BEGIN_SRC python :session jwt_example :exports code :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+import os
+import sys
+import subprocess
+
+os.chdir(os.path.expanduser('~/code/ox_jwt/src/ox_jwt'))
+my_env = os.environ.copy()
+my_env['FLASK_JWT_KEY'] = public_key.split('\n')[1]
+my_env['FLASK_JWT_ALGS'] = 'EdDSA,ES256'
+proc = subprocess.Popen([sys.executable, 'app.py'], env=my_env)
+# Use proc.kill() to shutdown server
+
+#+END_SRC
+
+#+RESULTS: start-flask
+
+
+* Example of =@requires_jwt=
+
+#+BEGIN_SRC python
+def requires_jwt(func):
+ @wraps(func)
+ def decorated(*args, **kwargs):
+ token = request.headers.get("Authorization").split(" ")[1]
+ if not token:
+ return 'missing token', 401 # if no token return error
+ try:
+ g.decoded_jwt = jwt.decode(
+ token, algorithms=['EdDSA'],
+ key=current_app.config['JWT_KEY']) # public key
+ return func(*args, **kwargs)
+ except Exception as problem:
+ return f'{problem=}', 401 # return 401 or other error code
+ return decorated
+#+END_SRC
+
+** Ensure Valid Token
+
+#+name: ensure-valid-token
+#+BEGIN_SRC python :session jwt_example :results output :exports both :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+import requests
+
+req = requests.get('http://127.0.0.1:5000/hello', headers={
+ 'Authorization': f'Bearer {example_jwt}mybad'}) # bad token
+print(f'Bad token response:\n code: {req.status_code}\n'
+ f' text: {req.text}\n')
+
+req = requests.get('http://127.0.0.1:5000/hello', headers={
+ 'Authorization': f'Bearer {example_jwt}'})
+print(f'Good token response:\n code: {req.status_code}\n'
+ f' text: {req.text}\n')
+#+END_SRC
+
+#+RESULTS: ensure-valid-token
+: Bad token response:
+: code: 401
+: text: problem=InvalidSignatureError('Signature verification failed')
+:
+: Good token response:
+: code: 200
+: text: Hello World!
+
+
+
+* Example of =@jwt_claims=
+
+#+COMMENT: should we include or skip if tight on time?
+#+COMMENT: or maybe have as backup slide
+
+#+BEGIN_SRC python
+def jwt_claims(claims_list: typing.Sequence[str]):
+ def make_decorator(func):
+ @wraps(func)
+ def decorated(*args, **kwargs):
+ missing = [c for c in claims_list
+ if not g.decoded_jwt.get(c)]
+ if missing:
+ return f'Missing claims: {missing}', 401
+ return func(*args, **kwargs)
+ return decorated
+ return make_decorator
+#+END_SRC
+
+** Ensure Claims (Bad Token)
+
+#+name: ensure-valid-claims-bad-token
+#+BEGIN_SRC python :session jwt_example :results output :exports both :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+import datetime, requests
+
+req = requests.get('http://127.0.0.1:5000/support/urgent', headers={
+ 'Authorization': f'Bearer {example_jwt}'}) # bad token
+
+print(f'Bad token response:\n code: {req.status_code}\n'
+ f' text: {req.text}\n')
+#+END_SRC
+
+#+RESULTS: ensure-valid-claims-bad-token
+: Bad token response:
+: code: 401
+: text: Missing claims: ['premium_user']
+
+** Ensure Claims (Bad Claims)
+
+#+name: ensure-valid-claims-bad-claim
+#+BEGIN_SRC python :session jwt_example :results output :exports both :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+
+premium_jwt = jwt.encode(headers={'typ':'JWT', 'alg':'EdDSA'},
+ payload={'sub': 'a', 'premium_user': 'b', 'iat': 1}, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/support/urgent', headers={
+ 'Authorization': f'Bearer {premium_jwt}'})
+
+print(f'Premium token response:\n code: {req.status_code}\n'
+ f' text: {req.text}\n')
+#+END_SRC
+
+#+RESULTS: ensure-valid-claims-bad-claim
+: Premium token response:
+: code: 401
+: text: Token age 20193 days, 17:37:05.670865 not within 0:00:30
+
+** Ensure Claims (Success)
+
+#+name: ensure-valid-claims-good
+#+BEGIN_SRC python :session jwt_example :results output :exports both :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+recent_premium_jwt = jwt.encode(headers={'typ':'JWT', 'alg':'EdDSA'},
+ payload={'sub': 'a', 'premium_user': 'b', 'iat': int(now)}, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/support/urgent', headers={
+ 'Authorization': f'Bearer {recent_premium_jwt}'})
+
+print(f'Recent premium token response:\n code: {req.status_code}\n'
+ f' text: {req.text}\n')
+#+END_SRC
+
+#+RESULTS: ensure-valid-claims-good
+: Recent premium token response:
+: code: 200
+: text: processing support request for user b
+
+* Example Use Case: Proxy
+
+#+ATTR_REVEAL: :frag (none appear appear)
+- Auth Server grants JWT letting Alice to act for Bob
+- claims: ={"sub": "Alice", "proxy": "Bob"}=
+- Alice sends request combining to act for Bob
+
+
+* Example Use Case: Proxy
+
+
+- Auth Server grants JWT letting Alice to act for Bob
+- claims: ={"sub": "Alice", "proxy": "Bob"}=
+- Alice sends request combining to act for Bob
+
+#+BEGIN_SRC python
+@APP.route("/issue")
+@requires_jwt
+def issue():
+ "Example route to create an issue."
+ user = g.decoded_jwt.get('proxy', g.decoded_jwt.get('sub'))
+ msg = f'Created issue assigned to {user}.'
+ # ... Create the actual issue here
+
+
+
+ return msg
+#+END_SRC
+
+* Example Use Case: Proxy
+
+- Auth Server grants JWT letting Alice to act for Bob
+- claims: ={"sub": "Alice", "proxy": "Bob"}=
+- Alice sends request combining to act for Bob
+
+#+BEGIN_SRC python
+@APP.route("/issue")
+@requires_jwt
+def issue():
+ "Example route to create an issue."
+ user = g.decoded_jwt.get('proxy', g.decoded_jwt.get('sub'))
+ msg = f'Created issue assigned to {user}.'
+ # ... Create the actual issue here
+ real_user = g.decoded_jwt['sub']
+ if real_user != user:
+ msg += f'\n{real_user} acted on behalf of {user}'
+ return msg
+#+END_SRC
+
+** Python demo
+#+name: proxy-example
+#+BEGIN_SRC python :session jwt_example :results output :exports both :python ~/code/ox_jwt/venv_ox_jwt/bin/python3 :eval never-export
+
+now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+proxy_example_jwt = jwt.encode(headers={'typ':'JWT', 'alg':'EdDSA'},
+ payload={'sub': 'Alice', 'proxy': 'Bob'}, key=sk)
+
+req = requests.get('http://127.0.0.1:5000/issue', headers={
+ 'Authorization': f'Bearer {proxy_example_jwt}'})
+print(req.text)
+
+#+END_SRC
+
+#+RESULTS: proxy-example
+: 127.0.0.1 - - [24/Apr/2025 13:25:28] "GET /issue HTTP/1.1" 200 -
+: Created issue assigned to Bob.
+: Alice acted on behalf of Bob
+
+
+
+
+* Anti-Patterns
+
+#+ATTR_REVEAL: :frag (appear appear appear)
+- Beware using header fields to check signature
+ - don't trust =alg= field or limit possibilities
+ - e.g., ~algorithms=['EdDSA']~
+ - be careful with =kid=, =jku=, =jwk=, etc.
+- Don't simulate sessions with JWTs
+- Token revocation issue: access/refresh tokens
+
+
+* Revocation via Access/Refresh
+ :PROPERTIES:
+ :ID: b06374ea-7534-4153-b5e6-8e2aa62a24c5
+ :END:
+
+
+#+ATTR_REVEAL: :frag (none appear)
+- Problem: Can't cancel or logout a JWT
+- Solution: Refresh/Access token
+ - "refresh token" with long expiry
+ - used to get access token w/o credential check
+ - "access token" with short expiry
+ - can be used to access services
+
+
+#+BEGIN_NOTES
+On security events (role changes, credential changes, hacks), auth
+server will invalidate refresh token + require new credential check.
+#+END_NOTES
+
+
+* Get Refresh Token
+
+#+name: jwt-get-refresh
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-get-refresh.jpg :eval never-export
+
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="Get JWT\nRefresh Token\n(long lived)", constraint=false, splines=ortho];
+ Client -> AuthServer [label="Authenticate\n(e.g., login\nor OAuth\nMFA, etc.)", constraint=false, splines=ortho];
+ Client -> AppServer [label="Send JWT to\nRequest Service", constraint=false, splines=ortho, style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+#+END_SRC
+
+#+RESULTS: jwt-get-refresh
+[[file:images/jwt-get-refresh.jpg]]
+
+
+* Get Access Token
+
+#+name: jwt-get-access
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-get-access.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="Get JWT\nAccess Token\n(short lived)", constraint=false, splines=ortho];
+ Client -> AuthServer [label="Send Refresh\nToken", constraint=false, splines=ortho];
+ Client -> AppServer [label="Send JWT to\nRequest Service", constraint=false, splines=ortho, style=invis];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+#+END_SRC
+
+#+RESULTS: jwt-get-access
+[[file:images/jwt-get-access.jpg]]
+
+
+* Use Access Token
+
+#+name: jwt-use-access
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-use-access.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="Get JWT\nAccess Token\n(short lived)", constraint=false, splines=ortho, style=invis];
+ Client -> AuthServer [label="Send Refresh\nToken", constraint=false, splines=ortho,style=invis];
+ Client -> AppServer [label="Send JWT\nAccess Token\nto Request Service", constraint=false, splines=ortho];
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+#+END_SRC
+
+#+RESULTS: jwt-use-access
+[[file:images/jwt-use-access.jpg]]
+
+
+* Revocation
+
+#+name: jwt-revoke
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/jwt-revoke.jpg :eval never-export
+
+digraph auth_system {
+ // Define subgraphs
+ subgraph top {
+ rank=same;
+ AuthServer [label="Auth Server", shape=box];
+ hidden [style=invis];
+ AppServer [label="App Server(s)", shape=box];
+ }
+
+ subgraph bottom {
+ rank=same;
+ Client [label="Client", shape=box];
+ }
+
+ // Define connections
+ AuthServer -> Client [label="Cancel Refresh\nToken. Require\nFresh Login", constraint=false, splines=ortho, penwidth=0, dir=none];
+ Client -> AuthServer [label="Logout/Cancel\n or Fraud\nDetected", constraint=false, splines=ortho, style=dashed];
+ Client -> AppServer [label="Send JWT\nAccess Token\nto Request Service", constraint=false, splines=ortho, style=invis];
+
+
+ // Define hidden edges to force layout
+ AuthServer -> hidden [style=invis];
+ hidden -> AppServer [style=invis];
+ hidden -> Client [style=invis];
+}
+#+END_SRC
+
+#+RESULTS: jwt-revoke
+[[file:images/jwt-revoke.jpg]]
+
+
+* Separate validation from parsing
+
+#+BEGIN_NOTES
+We can go one step beyond separating authentication from validation
+and separate validation from parsing.
+
+- aside: NGINX+JWTs can protect stand-alone sites
+#+END_NOTES
+
+#+ATTR_REVEAL: :frag (none appear appear)
+- Can use middleware to verify signature
+- e.g., NGINX can verify before passing to app server
+ #+RESULTS: nginx-example
+ [[file:images/nginx-example.jpg]]
+- See implementation in =nginx= directory:
+- https://github.com/aocks/ox_jwt ([[https://github.com/aocks/ox_jwt/blob/main/nginx/conf.d/example.conf#L44][example.conf]])
+
+#+name: nginx-example
+#+begin_src dot :cmdline -Kdot -Tjpg :exports results :file images/nginx-example.jpg :eval never-export
+
+,#+BEGIN_SRC dot
+digraph RequestFlow {
+ rankdir = LR;
+ request [label="request\nwith JWT"];
+ request -> nginx;
+
+ subgraph cluster_0 {
+ nginx [label="NGINX Server\nvalidates JWT\nefficiently"];
+ label="Application Server";
+ nginx -> "Flask server\nparses JWT claims";
+ }
+}
+#+END_SRC
+
+
+* Summary and next steps
+
+#+BEGIN_NOTES
+If you are writing a small application, you can quickly and easily put
+together a secure system using various JWT libraries.
+
+If you are doing a full enterprise authentication system, you may want
+to go with an existing platform. Many of those use JWTs under the hood
+so it's still useful to have a high level understanding of the basic diea.
+#+END_NOTES
+
+#+ATTR_REVEAL: :frag (none none none appear appear appear)
+- Distributed trust can enable many use cases
+- JWTs = secure, efficient, standardized auth tool
+- Python decorators = nice way to validate claims
+- Libraries:
+ - [[https://pyjwt.readthedocs.io/en/stable/][pyjwt]], [[https://flask-jwt-extended.readthedocs.io/en/stable/][flask-jwt-extended]], [[https://django-rest-framework-simplejwt.readthedocs.io/en/latest/][djangorestframework-simplejwt]]
+- Platforms:
+ - [[https://auth0.com][auth0]], [[https://supertokens.com/][supertokens]], [[https://docs.aws.amazon.com/cognito/][cognito]], [[https://www.keycloak.org/][keycloak]]
+- Slides/examples: https://github.com/aocks/ox_jwt/
+
+
+
+
+
+
diff --git a/makefile b/makefile
index 73ff66a..b35e092 100644
--- a/makefile
+++ b/makefile
@@ -7,19 +7,39 @@
.DEFAULT_GOAL := help
# Indicate targets which are not files but commands
-.PHONY: clean help test
-
-help:
- @echo " "
- @echo " The following targets are available:"
- @echo " "
- @echo "test: Build and test ojwt.js file, JWT, and test with nginx."
- @echo "clean: Clean out intermediate files which we can auto-generate."
- @echo "help: Shows this help message."
- @echo " "
-
-test:
+.PHONY: clean help test pylint
+
+# The stuff below implements an auto help feature
+define PRINT_HELP_PYSCRIPT
+import re, sys
+
+for line in sys.stdin:
+ match = re.match(r'^([.a-zA-Z_-]+):.*?## (.*)$$', line)
+ if match:
+ target, help = match.groups()
+ print("%-20s %s" % (target, help))
+endef
+export PRINT_HELP_PYSCRIPT
+
+help: ## Show help for avaiable targets.
+ @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
+
+
+README.md: README.org ## Create markdown README file.
+ pandoc --from=org --to=markdown -o $@ $<
+
+pylint: ## Run pylint on python files.
+ pylint src/ox_jwt
+
+test_python: ## Test python code
+ py.test src/ox_jwt
+
+test_nginx: ## Test nginx.
$(MAKE) -C nginx test_ojwt_nginx
-clean:
+test: ## Test everything.
+ $(MAKE) test_python
+ $(MAKE) test_nginx
+
+clean: ## Clean up generated files.
$(MAKE) -C nginx clean
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..42fab47
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,41 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "ox_jwt"
+version = "0.1.0"
+description = "Demonstration of how to use JWTs."
+authors = [
+ { name = "Emin Martinian", email = "emin@aocks.com" }
+]
+license-files = ["LICEN[CS]E*"]
+readme = "README.md"
+dependencies = [
+ "requests",
+ "flask",
+ "pyjwt[crypto]",
+ "pytest"
+]
+
+[project.urls]
+Homepage = "https://github.com/aocks/ox_jwt"
+Documentation = "https://github.com/aocks/ox_jwt"
+Repository = "https://github.com/aocks/ox_jwt.git"
+Issues = "https://github.com/aocks/ox_jwt/issues"
+
+
+[project.optional-dependencies]
+dev = [
+ "pytest",
+ "pylint",
+]
+
+
+[tool.setuptools]
+# If there are data files included in your packages that need to be
+# installed, specify them here.
+package-data = { "sample" = ["*.dat"] }
+
+[project.scripts]
+test_ox_jwt = "ox_jwt.test_app:main"
\ No newline at end of file
diff --git a/src/ox_jwt/__init__.py b/src/ox_jwt/__init__.py
new file mode 100644
index 0000000..c17f6bb
--- /dev/null
+++ b/src/ox_jwt/__init__.py
@@ -0,0 +1,2 @@
+"""Project to show how to use JWTs.
+"""
diff --git a/src/ox_jwt/app.py b/src/ox_jwt/app.py
new file mode 100644
index 0000000..3a6ffdc
--- /dev/null
+++ b/src/ox_jwt/app.py
@@ -0,0 +1,108 @@
+"""Simple Flask app to demonstrate how JWTs work.
+
+See accompanying slides for more documentation.
+"""
+
+import base64
+import datetime
+from functools import wraps
+import typing
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+
+import jwt
+from flask import Flask, request, g, current_app
+
+
+APP = Flask(__name__) # Update config from env vars like FLASK_*
+APP.config.from_prefixed_env() # pylint: disable=no-member
+
+
+def requires_jwt(func):
+ """Decorator to verify that valid jwt present in header.
+
+This decorator also puts the decoded JWT into g.decoded_jwt so
+that other decorators (and general) functions can look at the
+JWT info without having to redo validation.
+ """
+ @wraps(func)
+ def decorated(*args, **kwargs):
+ token = request.headers.get("Authorization", "NA ").split(" ")[1]
+ # if no token return error
+ try:
+ key = serialization.load_der_public_key(
+ base64.b64decode(current_app.config['JWT_KEY']),
+ backend=default_backend())
+ g.decoded_jwt = jwt.decode(
+ token, algorithms=current_app.config['JWT_ALGS'].split(','),
+ key=key)
+ return func(*args, **kwargs)
+ except Exception as problem: # pylint: disable=broad-except
+ return f'{problem=}', 401 # return 401 or other error code
+ return decorated
+
+
+def jwt_claims(claims_list: typing.Sequence[str]):
+ "Decorator to verify jwt contains given sequence of strings."
+ def make_decorator(func):
+ @wraps(func)
+ def decorated(*args, **kwargs):
+ missing = [c for c in claims_list if not g.decoded_jwt.get(c)]
+ if missing:
+ return f'Missing claims: {missing}', 401
+ return func(*args, **kwargs)
+ return decorated
+ return make_decorator
+
+
+def jwt_iat(within: datetime.timedelta):
+ "Decorator to check that iat in jwt is within given timedelta."
+ def make_decorator(func):
+ @wraps(func)
+ def decorated(*args, **kwargs):
+ iat = g.decoded_jwt.get('iat', None)
+ if iat is None:
+ return 'Missing iat', 401
+ age = datetime.datetime.utcnow(
+ ) - datetime.datetime.utcfromtimestamp(iat)
+ if age <= within:
+ return func(*args, **kwargs)
+ return f'Token age {age} not within {within}', 401
+ return decorated
+ return make_decorator
+
+
+@APP.route("/issue")
+@requires_jwt
+def issue():
+ "Example route to create an issue."
+ effective_user = g.decoded_jwt.get('proxy', g.decoded_jwt.get('sub'))
+ # ... Create the actual issue here
+ msg = f'Created issue assigned to {effective_user}.'
+ real_user = g.decoded_jwt['sub']
+ if real_user != effective_user:
+ msg += f'\n{real_user} acted on behalf of {effective_user}'
+ return msg
+
+
+@APP.route("/hello")
+@requires_jwt
+def hello():
+ "Example route which just verifies jwt present (no claim checks)."
+ return "Hello World!"
+
+
+@APP.route('/support/urgent')
+@requires_jwt
+@jwt_claims(['premium_user'])
+@jwt_iat(datetime.timedelta(seconds=30))
+def support_urgent():
+ "Example route for urgent support request (checks iat and claims)."
+ return (f'processing support request for'
+ f' user {g.decoded_jwt["premium_user"]}')
+
+
+
+if __name__ == "__main__":
+ APP.run(debug=False)
diff --git a/src/ox_jwt/test_app.py b/src/ox_jwt/test_app.py
new file mode 100644
index 0000000..6578efb
--- /dev/null
+++ b/src/ox_jwt/test_app.py
@@ -0,0 +1,179 @@
+"""Simple test file to verify demo in app.py
+"""
+
+import base64
+import datetime
+import os
+import socket
+import subprocess
+import sys
+import time
+import typing
+
+from cryptography.hazmat.primitives.asymmetric import ed25519
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.backends import default_backend
+import jwt
+import pytest
+import requests
+
+
+class FlaskServerManager:
+ """Class to manage flask server.
+ """
+
+ process = None
+ port = None
+ secret_key = None
+ public_key = None
+
+ @staticmethod
+ def get_free_port() -> int:
+ "Finds and returns an available port on the system."
+ sock = socket.socket()
+ sock.bind(('', 0))
+ port = sock.getsockname()[1]
+ sock.close()
+ return port
+
+ @classmethod
+ def teardown(cls):
+ "Tear down subprocess for server."
+
+ if cls.process is not None:
+ cls.process.kill()
+ cls.process = None
+
+ @classmethod
+ def setup(cls):
+ """Setup server (and keys) and run in subprocess
+ """
+ cls.teardown() # in case process is active
+ cls.secret_key, cls.public_key = make_keys()
+ cls.port = cls.get_free_port()
+ os.chdir(os.path.dirname(__file__))
+ env = os.environ.copy()
+ env['FLASK_JWT_KEY'] = cls.public_key.split('\n')[1] # get DER piece
+ env['FLASK_JWT_ALGS'] = 'EdDSA,ES256'
+ cls.process = subprocess.Popen([ # pylint: disable=consider-using-with
+ 'flask', 'run', '--port', str(cls.port)], env=env)
+
+
+def make_keys(secret_key=None):
+ "Make secret and public key."
+
+ if secret_key is None:
+ secret_key = ed25519.Ed25519PrivateKey.generate()
+
+ serialized_secret_key = base64.b64encode( # serialize key
+ secret_key.private_bytes(
+ encoding=serialization.Encoding.DER,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption())
+ ).decode('utf8')
+
+ public_key = secret_key.public_key()
+
+ serialized_public_key = public_key.public_bytes( # serialize
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
+ ).decode('utf8')
+
+ return serialized_secret_key, serialized_public_key
+
+
+def make_jwt(secret_key: str, headers: typing.Optional[dict] = None,
+ payload: typing.Optional[dict] = None) -> str:
+ """Create JWT.
+
+ :param secret_key: String represening serialized secret key.
+
+ :param headers=None: Optional dictionary of headers for JWT.
+
+ :param payload=None: Optional dictionary for payload of JWT.
+
+ ~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-
+
+ :return: Encoded JWT.
+
+ ~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-~-
+
+ PURPOSE: Create JWT from secret key, headers, and payload.
+
+ """
+ sk = serialization.load_der_private_key( # de-serialize encoded key
+ base64.b64decode(secret_key), backend=default_backend(),
+ password=None)
+ my_jwt = jwt.encode(
+ headers=headers or {'typ': 'JWT', 'alg': 'EdDSA'},
+ payload=payload or {'sub': 'a', 'name': 'b', 'iat': 1},
+ key=sk)
+ return my_jwt
+
+
+@pytest.fixture(scope="session", autouse=True)
+def manage_flask_server_for_tests():
+ "Pytest fixture to setup/teardown flask server for tests."
+
+ print('\nSetup FlaskServerManager\n')
+ FlaskServerManager.setup()
+ time.sleep(1) # let server get started
+ yield
+ print('\nTeardown FlaskServerManager\n')
+ FlaskServerManager.teardown()
+
+
+def test_simple_enc_dec():
+ """Simple test of encoding/decoding.
+ """
+ my_jwt = make_jwt(FlaskServerManager.secret_key)
+ req = requests.get(f'http://127.0.0.1:{FlaskServerManager.port}/hello',
+ headers={'Authorization': f'Bearer {my_jwt}'},
+ timeout=30)
+ assert req.status_code == 200
+ assert req.text == 'Hello World!'
+
+ bad_req = requests.get(f'http://127.0.0.1:{FlaskServerManager.port}/hello',
+ headers={'Authorization': f'Bearer {my_jwt}mybad'},
+ timeout=30)
+ assert bad_req.status_code == 401
+ assert bad_req.text == (
+ "problem=InvalidSignatureError('Signature verification failed')")
+
+
+def test_premium_enc_dec():
+ """Test encoding/decoding with claims for premium user.
+ """
+ now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+ my_jwt = make_jwt(FlaskServerManager.secret_key, payload={
+ 'premium_user': 'user@example.com', 'iat': int(now)})
+ req = requests.get(
+ f'http://127.0.0.1:{FlaskServerManager.port}/support/urgent',
+ headers={'Authorization': f'Bearer {my_jwt}'}, timeout=30)
+ assert req.status_code == 200
+ assert req.text == 'processing support request for user user@example.com'
+
+
+def test_proxy():
+ """Test how a proxy user would owrk.
+ """
+ now = datetime.datetime.now(tz=datetime.timezone.utc).timestamp()
+ my_jwt = make_jwt(FlaskServerManager.secret_key, payload={
+ 'sub': 'Alice', 'proxy': 'Bob', 'iat': int(now)})
+ req = requests.get(
+ f'http://127.0.0.1:{FlaskServerManager.port}/issue',
+ headers={'Authorization': f'Bearer {my_jwt}'}, timeout=30)
+ assert req.status_code == 200
+ assert req.text == (
+ 'Created issue assigned to Bob.\nAlice acted on behalf of Bob')
+
+
+def main():
+ "Run tests if module is run as main command."
+
+ print(f'\n\n Running tests in {__file__} \n\n')
+ sys.exit(pytest.main([__file__, '-s', '-vvv']))
+
+
+if __name__ == '__main__':
+ main()