Packages
attesto_phoenix
2.0.2
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/attesto_phoenix.gen.dev_https.ex
defmodule Mix.Tasks.AttestoPhoenix.Gen.DevHttps do
@shortdoc "Generates a locally-trusted (mkcert) TLS certificate for local https development"
@moduledoc """
Sets up a locally-trusted TLS certificate so a dev endpoint can serve
`https://localhost` with no tunnel and no downgrade.
attesto requires an **https** issuer (RFC 8414 §2), so a plain `http://localhost`
dev server cannot drive the OAuth / MCP flow. This task generates a certificate
from a local certificate authority — [mkcert](https://github.com/FiloSottile/mkcert) —
that your machine already trusts, so `https://localhost` works everywhere with
no `-k` and no self-signed warnings.
The task:
1. checks `mkcert` is on your `PATH` (printing install guidance if not),
2. creates the cert directory (default `priv/cert`),
3. runs `mkcert -install` (idempotent — trusts the local CA on this machine),
4. writes `localhost.pem` + `localhost-key.pem` for the requested hosts,
5. ensures the cert directory is git-ignored, and
6. prints the exact `config/dev.exs` one-liner to wire it up.
Re-running regenerates cleanly.
## Example
```sh
mix attesto_phoenix.gen.dev_https
```
Then wire it into `config/dev.exs` (the task prints this):
```elixir
config :my_app, MyAppWeb.Endpoint, https: AttestoPhoenix.DevTLS.https_opts(port: 4443)
```
and make sure your MCP / OAuth issuer is `https://localhost:4443`.
## Options
* `--host` - the space-separated host list the certificate is valid for.
Defaults to `localhost 127.0.0.1 ::1`.
* `--dir` - the directory the cert/key are written to. Defaults to
`priv/cert`.
## Production
mkcert is a **local dev** CA. Never run `mkcert -install` or use these
certificates on a server or in CI — production terminates TLS at the load
balancer / ingress with a real CA certificate.
"""
use Mix.Task
@default_hosts "localhost 127.0.0.1 ::1"
@default_dir "priv/cert"
@cert_basename "localhost.pem"
@key_basename "localhost-key.pem"
@default_port 4443
@switches [host: :string, dir: :string]
@impl Mix.Task
def run(args) do
{opts, _argv, _invalid} = OptionParser.parse(args, switches: @switches)
dir = Keyword.get(opts, :dir, @default_dir)
hosts = Keyword.get(opts, :host, @default_hosts)
if mkcert_available?() do
generate(dir, hosts)
ensure_gitignored(dir)
Mix.shell().info(next_steps(dir))
else
Mix.shell().error(mkcert_install_guidance())
end
end
# --- generation (guarded behind the mkcert-availability seam above) ---
defp generate(dir, hosts) do
File.mkdir_p!(dir)
certfile = Path.join(dir, @cert_basename)
keyfile = Path.join(dir, @key_basename)
Mix.shell().info("Installing the local mkcert CA (idempotent)…")
mkcert(["-install"])
Mix.shell().info("Writing #{certfile} + #{keyfile} for: #{hosts}")
mkcert(["-cert-file", certfile, "-key-file", keyfile | String.split(hosts, " ", trim: true)])
end
# The single seam every mkcert invocation flows through. Tests stub
# `mkcert_available?/0` to false (exercising the guidance branch) or drive the
# pure helpers directly, so no test ever shells out to mkcert.
defp mkcert_available? do
System.find_executable("mkcert") != nil
end
defp mkcert(args) do
case Mix.shell().cmd("mkcert " <> Enum.map_join(args, " ", &shell_escape/1)) do
0 -> :ok
status -> Mix.raise("mkcert exited with status #{status}. Args: #{inspect(args)}")
end
end
# mkcert args here are cert paths and host tokens; quote each so a path with a
# space survives the shell.
defp shell_escape(arg), do: "'" <> String.replace(arg, "'", "'\\''") <> "'"
# --- git-ignore handling (pure, testable) ---
@doc false
# Ensures `<dir>/` is git-ignored, appending a single entry to `.gitignore`
# (creating it if absent) without duplicating one already present. Returns
# `:already` when the pattern was present, `:appended` when it was added.
@spec ensure_gitignored(String.t(), String.t()) :: :already | :appended
def ensure_gitignored(dir, gitignore_path \\ ".gitignore") do
pattern = gitignore_pattern(dir)
existing = if File.exists?(gitignore_path), do: File.read!(gitignore_path), else: ""
if gitignore_has_pattern?(existing, pattern) do
:already
else
prefix = if existing == "" or String.ends_with?(existing, "\n"), do: "", else: "\n"
block =
prefix <>
"\n# Locally-trusted mkcert dev certificate (mix attesto_phoenix.gen.dev_https).\n" <>
pattern <> "\n"
File.write!(gitignore_path, existing <> block)
:appended
end
end
# The ignore pattern for the cert dir: a leading-slash, trailing-slash form so
# it matches the directory anywhere it sits relative to .gitignore.
@doc false
@spec gitignore_pattern(String.t()) :: String.t()
def gitignore_pattern(dir) do
"/" <> (dir |> Path.relative_to_cwd() |> String.trim_leading("/") |> String.trim_trailing("/")) <> "/"
end
# True when the .gitignore already ignores this dir, tolerating the with/without
# leading- and trailing-slash spellings a dev may already have.
defp gitignore_has_pattern?(contents, pattern) do
bare = pattern |> String.trim_leading("/") |> String.trim_trailing("/")
accepted =
MapSet.new([pattern, bare, bare <> "/", "/" <> bare, "/" <> bare <> "/"])
contents
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.any?(&MapSet.member?(accepted, &1))
end
# --- instruction rendering (pure, testable) ---
@doc false
@spec next_steps(String.t()) :: String.t()
def next_steps(dir) do
certfile = Path.join(dir, @cert_basename)
keyfile = Path.join(dir, @key_basename)
"""
Locally-trusted certificate ready:
#{certfile}
#{keyfile}
1. Wire it into config/dev.exs (one line):
config :my_app, MyAppWeb.Endpoint,
https: AttestoPhoenix.DevTLS.https_opts(port: #{@default_port})
2. Point your MCP / OAuth issuer at the https port:
https://localhost:#{@default_port}
attesto requires an https issuer (RFC 8414 §2); serving this locally-trusted
cert is the tunnel-free way to satisfy it — no downgrade, no `-k`.
3. Start the server and verify:
mix phx.server
curl https://localhost:#{@default_port}/.well-known/oauth-authorization-server
Note: mkcert is a local dev CA. Never use it on a server or in CI; production
terminates TLS with a real certificate at the load balancer / ingress.
"""
end
@doc false
@spec mkcert_install_guidance() :: String.t()
def mkcert_install_guidance do
"""
mkcert was not found on your PATH.
mkcert generates a locally-trusted certificate so `https://localhost` works
with no tunnel and no self-signed warnings. Install it, then re-run this task:
macOS: brew install mkcert nss # nss adds Firefox trust
Linux: see https://github.com/FiloSottile/mkcert#installation
Windows: choco install mkcert
Then:
mix attesto_phoenix.gen.dev_https
"""
end
end