diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1490f71a..07e2476b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,12 +18,21 @@ Please file a bug if you notice a violation of semantic versioning.
## [Unreleased]
### Added
+- E2E example using mock test server added in v2.0.11
+ - mock-oauth2-server upgraded to v2.3.0
+ - https://github.com/navikt/mock-oauth2-server
+ - `docker compose -f docker-compose-ssl.yml up -d --wait`
+ - `ruby examples/e2e.rb`
+ - `docker compose -f docker-compose-ssl.yml down`
+ - mock server readiness wait is 90s
+ - override via E2E_WAIT_TIMEOUT
- Apache SkyWalking Eyes dependency license check
### Changed
- Many improvements to make CI more resilient (past/future proof)
### Deprecated
### Removed
### Fixed
+
### Security
## [2.0.15] - 2025-09-08
diff --git a/README.md b/README.md
index 2493fec4..bd424dbb 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ OAuth 2.0 focuses on client developer simplicity while providing specific author
desktop applications, mobile phones, and living room devices.
This is a RubyGem for implementing OAuth 2.0 clients (not servers) in Ruby applications.
-### Quick Example
+### Quick Examples
Convert the following `curl` command into a token request using this gem...
@@ -61,6 +61,61 @@ NOTE: `header` - The content type specified in the `curl` is already the default
+
+Complete E2E single file script against [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server)
+
+- E2E example using the mock test server added in v2.0.11
+
+```console
+docker compose -f docker-compose-ssl.yml up -d --wait
+ruby examples/e2e.rb
+# If your machine is slow or Docker pulls are cold, increase the wait:
+E2E_WAIT_TIMEOUT=120 ruby examples/e2e.rb
+# The mock server serves HTTP on 8080; the example points to http://localhost:8080 by default.
+```
+
+The output should be something like this:
+
+```console
+➜ ruby examples/e2e.rb
+Access token (truncated): eyJraWQiOiJkZWZhdWx0...
+userinfo status: 200
+userinfo body: {"sub" => "demo-sub", "aud" => ["demo-aud"], "nbf" => 1757816758000, "iss" => "http://localhost:8080/default", "exp" => 1757820358000, "iat" => 1757816758000, "jti" => "d63b97a7-ebe5-4dea-93e6-d542caba6104"}
+E2E complete
+```
+
+Make sure to shut down the mock server when you are done:
+
+```console
+docker compose -f docker-compose-ssl.yml down
+```
+
+Troubleshooting: validate connectivity to the mock server
+
+- Check container status and port mapping:
+ - docker compose -f docker-compose-ssl.yml ps
+- From the host, try the discovery URL directly (this is what the example uses by default):
+ - curl -v http://localhost:8080/default/.well-known/openid-configuration
+ - If that fails immediately, also try: curl -v --connect-timeout 2 http://127.0.0.1:8080/default/.well-known/openid-configuration
+- From inside the container (to distinguish container vs host networking):
+ - docker exec -it oauth2-mock-oauth2-server-1 curl -v http://127.0.0.1:8080/default/.well-known/openid-configuration
+- Simple TCP probe from the host:
+ - nc -vz localhost 8080 # or: ruby -rsocket -e 'TCPSocket.new("localhost",8080).close; puts "tcp ok"'
+- Inspect which host port 8080 is bound to (should be 8080):
+ - docker inspect -f '{{ (index (index .NetworkSettings.Ports "8080/tcp") 0).HostPort }}' oauth2-mock-oauth2-server-1
+- Look at server logs for readiness/errors:
+ - docker logs -n 200 oauth2-mock-oauth2-server-1
+- On Linux, ensure nothing else is bound to 8080 and that firewall/SELinux aren’t blocking:
+ - ss -ltnp | grep :8080
+
+Notes
+- Discovery URL pattern is: http://localhost:8080//.well-known/openid-configuration, where defaults to "default".
+- You can change these with env vars when running the example:
+ - E2E_ISSUER_BASE (default: http://localhost:8080)
+ - E2E_REALM (default: default)
+
+
+
If it seems like you are in the wrong place, you might try one of these:
* [OAuth 2.0 Spec][oauth2-spec]
diff --git a/config-ssl.json b/config-ssl.json
index f0a8da2e..f906d9e2 100644
--- a/config-ssl.json
+++ b/config-ssl.json
@@ -1,7 +1,21 @@
{
"interactiveLogin": true,
"httpServer": {
- "type": "NettyWrapper",
- "ssl": {}
- }
-}
\ No newline at end of file
+ "type": "NettyWrapper"
+ },
+ "tokenCallbacks": [
+ {
+ "issuerId": "default",
+ "requestMappings": [
+ {
+ "requestParam": "grant_type",
+ "match": "client_credentials",
+ "claims": {
+ "sub": "demo-sub",
+ "aud": ["demo-aud"]
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/docker-compose-ssl.yml b/docker-compose-ssl.yml
index 9a17fbba..f482d41d 100644
--- a/docker-compose-ssl.yml
+++ b/docker-compose-ssl.yml
@@ -1,9 +1,9 @@
services:
mock-oauth2-server:
- image: ghcr.io/navikt/mock-oauth2-server:2.1.11
+ image: ghcr.io/navikt/mock-oauth2-server:2.3.0
+ restart: unless-stopped
ports:
- "8080:8080"
- hostname: host.docker.internal
volumes:
- ./config-ssl.json:/app/config.json:Z
environment:
diff --git a/examples/e2e.rb b/examples/e2e.rb
new file mode 100644
index 00000000..22329c1a
--- /dev/null
+++ b/examples/e2e.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+# End-to-end example using oauth2 gem against a local mock-oauth2-server.
+# Prerequisites:
+# 1) Start the mock server (HTTP on 8080):
+# docker compose -f docker-compose-ssl.yml up -d --wait
+# 2) Run this script:
+# ruby examples/e2e.rb
+# 3) Stop the server when you're done:
+# docker compose -f docker-compose-ssl.yml down
+# Notes:
+# - The mock server uses a self-signed certificate. SSL verification is disabled in this example.
+# - Tested down to Ruby 2.4 (avoid newer syntax).
+
+require "oauth2"
+require "json"
+require "net/http"
+require "uri"
+
+module E2E
+ class ClientCredentialsDemo
+ attr_reader :client_id, :client_secret, :issuer_base, :realm
+
+ # issuer_base: e.g., https://localhost:8080
+ # realm: mock-oauth2-server issuer id ("default" by default)
+ def initialize(client_id, client_secret, issuer_base, realm)
+ @client_id = client_id
+ @client_secret = client_secret
+ @issuer_base = issuer_base
+ @realm = realm
+ end
+
+ def run
+ wait_for_server_ready
+ well_known = discover
+ token = fetch_token(well_known)
+ puts "Access token (truncated): #{token.token[0, 20]}..."
+ call_userinfo(well_known, token)
+ puts "E2E complete"
+ end
+
+ private
+
+ def discovery_url
+ File.join(@issuer_base, @realm, "/.well-known/openid-configuration")
+ end
+
+ def wait_for_server_ready(timeout = nil)
+ timeout = (timeout || ENV["E2E_WAIT_TIMEOUT"] || 90).to_i
+ uri = URI(discovery_url)
+ deadline = Time.now + timeout
+ announced = false
+ loop do
+ begin
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ req = Net::HTTP::Get.new(uri.request_uri)
+ res = http.request(req)
+ return if res.code.to_i == 200
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, SocketError, EOFError, OpenSSL::SSL::SSLError
+ # ignore and retry until timeout
+ end
+ unless announced
+ puts "Waiting for mock OAuth2 server at #{uri} ..."
+ announced = true
+ end
+ break if Time.now >= deadline
+ sleep(0.5)
+ end
+ raise "Server not reachable at #{uri} within #{timeout}s. Ensure it's running: docker compose -f docker-compose-ssl.yml up -d --wait. You can increase the wait by setting E2E_WAIT_TIMEOUT (seconds)."
+ end
+
+ def discover
+ uri = URI(discovery_url)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ req = Net::HTTP::Get.new(uri.request_uri)
+ res = http.request(req)
+ unless res.code.to_i == 200
+ raise "Discovery failed: #{res.code} #{res.message} - #{res.body}"
+ end
+ data = JSON.parse(res.body)
+ # Expect token_endpoint and possibly userinfo_endpoint
+ data
+ end
+
+ def fetch_token(well_known)
+ client = OAuth2::Client.new(
+ @client_id,
+ @client_secret,
+ site: @issuer_base,
+ token_url: URI.parse(well_known["token_endpoint"]).request_uri,
+ ssl: {verify: false},
+ auth_scheme: :request_body, # send client creds in request body (compatible default for mock servers)
+ )
+ # Use client_credentials grant
+ client.client_credentials.get_token
+ end
+
+ def call_userinfo(well_known, token)
+ userinfo = well_known["userinfo_endpoint"]
+ unless userinfo
+ puts "No userinfo_endpoint advertised by server; skipping userinfo call."
+ return
+ end
+ uri = URI(userinfo)
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = uri.scheme == "https"
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
+ req = Net::HTTP::Get.new(uri.request_uri)
+ req["Authorization"] = "Bearer #{token.token}"
+ res = http.request(req)
+ puts "userinfo status: #{res.code} #{res.message}"
+ if res.code.to_i == 200
+ begin
+ body = JSON.parse(res.body)
+ rescue StandardError
+ body = res.body
+ end
+ puts "userinfo body: #{body.inspect}"
+ else
+ puts "userinfo error body: #{res.body}"
+ end
+ end
+ end
+end
+
+if __FILE__ == $PROGRAM_NAME
+ # These must match the mock server configuration (see config-ssl.json)
+ client_id = ENV["E2E_CLIENT_ID"] || "demo-client"
+ client_secret = ENV["E2E_CLIENT_SECRET"] || "demo-secret"
+ issuer_base = ENV["E2E_ISSUER_BASE"] || "http://localhost:8080"
+ realm = ENV["E2E_REALM"] || "default"
+
+ E2E::ClientCredentialsDemo.new(client_id, client_secret, issuer_base, realm).run
+end