Skip to content

Commit de49ed9

Browse files
committed
Initial commit
0 parents  commit de49ed9

File tree

8 files changed

+237
-0
lines changed

8 files changed

+237
-0
lines changed

.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Used by "mix format"
2+
[
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# The directory Mix will write compiled artifacts to.
2+
/_build/
3+
4+
# If you run "mix test --cover", coverage assets end up here.
5+
/cover/
6+
7+
# The directory Mix downloads your dependencies sources to.
8+
/deps/
9+
10+
# Where third-party dependencies like ExDoc output generated docs.
11+
/doc/
12+
13+
# If the VM crashes, it generates a dump, let's ignore it too.
14+
erl_crash.dump
15+
16+
# Also ignore archive artifacts (built via "mix archive.build").
17+
*.ez
18+
19+
# Ignore package tarball (built via "mix hex.build").
20+
otpauth-*.tar
21+
22+
# Temporary files, for example, from tests.
23+
/tmp/

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# OTPAuth
2+
3+
** Decode otpauth URI and extract secret
4+
5+
## Installation
6+
7+
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
8+
by adding `otpauth` to your list of dependencies in `mix.exs`:
9+
10+
```elixir
11+
def deps do
12+
[
13+
{:otpauth, "~> 0.1.0"}
14+
]
15+
end
16+
```
17+
18+
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
19+
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
20+
be found at <https://hexdocs.pm/otpauth>.
21+

lib/otpauth.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
defmodule OTPAuth do
2+
@moduledoc """
3+
This module decodes `otpauth://totp/…` URIs and extract the various fields.
4+
5+
Once the secret is extracted, it can be used with the
6+
[NimbleTOTP](https://hex.pm/packages/nimble_totp) library to generate the current secret.
7+
"""
8+
9+
@doc """
10+
Extract the secret and label from an otpauth URI, as well as the extra properties.
11+
12+
The issuer will be extracted either from the label prefix or from the extra URI parameter.
13+
If both are present, they have to be equal or an error will be returned.
14+
15+
## Examples
16+
17+
iex> OTPAuth.decompose_uri("otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme")
18+
{:ok, "abcd", "alice", %{"issuer" => "Acme"}}
19+
20+
iex> OTPAuth.decompose_uri("otpauth://totp/Acme:alice?secret=INVALID!&issuer=Acme")
21+
:error
22+
23+
"""
24+
@spec decompose_uri(String.t()) ::
25+
{:ok, binary(), String.t(), map()} | {:error, :invalid_uri}
26+
def decompose_uri(uri) when is_binary(uri) do
27+
with true <- uri =~ ~r"^otpauth://totp/",
28+
{:ok, uri} <- URI.new(uri),
29+
["", label] <- String.split(uri.path, "/"),
30+
# Reject empty prefix issuer or empty labels (with or without prefix issuer)
31+
false <- label =~ ~r/^(:|$|.*:$)/,
32+
query = URI.decode_query(uri.query, %{}, :rfc3986),
33+
{secret, query} <- Map.pop(query, "secret"),
34+
{:ok, secret} <- Base.decode32(secret, padding: false),
35+
param_issuer <- Map.get(query, "issuer"),
36+
false <- (param_issuer || "") =~ ":" do
37+
case String.split(label |> URI.decode(), ":") do
38+
[label] when param_issuer != "" ->
39+
{:ok, secret, label, query}
40+
41+
[^param_issuer, label] ->
42+
{:ok, secret, label, query}
43+
44+
[issuer, label] when is_nil(param_issuer) ->
45+
{:ok, secret, label, Map.put(query, "issuer", issuer)}
46+
47+
_ ->
48+
:error
49+
end
50+
else
51+
_ -> :error
52+
end
53+
end
54+
end

mix.exs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule OTPAuth.MixProject do
2+
use Mix.Project
3+
4+
def project do
5+
[
6+
app: :otpauth,
7+
version: "0.1.0",
8+
elixir: "~> 1.18",
9+
start_permanent: Mix.env() == :prod,
10+
deps: deps(),
11+
12+
# Docs
13+
name: "OTPAuth",
14+
source_url: "https://github.com/evenfurther/otpauth",
15+
docs: &docs/0
16+
]
17+
end
18+
19+
defp deps do
20+
[
21+
{:ex_doc, "~> 0.34", only: :dev, runtime: false, warn_if_outdated: true}
22+
]
23+
end
24+
25+
defp docs do
26+
[
27+
main: "OTPAuth",
28+
extras: ["README.md"]
29+
]
30+
end
31+
end

mix.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
%{
2+
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
3+
"ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"},
4+
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
5+
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
6+
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
7+
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
8+
}

test/otpauth_test.exs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
defmodule OTPAuthTest do
2+
use ExUnit.Case, async: true
3+
doctest OTPAuth
4+
5+
describe "decompose_uri" do
6+
test "supports the absence of issuer" do
7+
uri = "otpauth://totp/alice?secret=MFRGGZA"
8+
assert {:ok, "abcd", "alice", %{}} == OTPAuth.decompose_uri(uri)
9+
end
10+
11+
test "extracts the issuer from the prefix" do
12+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA"
13+
14+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
15+
OTPAuth.decompose_uri(uri)
16+
end
17+
18+
test "extracts the issuer from the URI params" do
19+
uri = "otpauth://totp/alice?secret=MFRGGZA&issuer=Acme"
20+
21+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
22+
OTPAuth.decompose_uri(uri)
23+
end
24+
25+
test "accepts identical issuers in prefix and URI params" do
26+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme"
27+
28+
assert {:ok, "abcd", "alice", %{"issuer" => "Acme"}} ==
29+
OTPAuth.decompose_uri(uri)
30+
end
31+
32+
test "preserves extra URI params" do
33+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Acme&extra=1"
34+
35+
{:ok, "abcd", "alice", params} = OTPAuth.decompose_uri(uri)
36+
assert [{"extra", "1"}, {"issuer", "Acme"}] == Enum.sort(params)
37+
end
38+
39+
test "rejects an empty label with no prefix issuer" do
40+
uri = "otpauth://totp/?secret=MFRGGZA"
41+
42+
assert :error == OTPAuth.decompose_uri(uri)
43+
end
44+
45+
test "rejects an empty label with a prefix issuer" do
46+
uri = "otpauth://totp/Acme:?secret=MFRGGZA"
47+
48+
assert :error == OTPAuth.decompose_uri(uri)
49+
end
50+
51+
test "rejects an empty prefix issuer" do
52+
uri = "otpauth://totp/:alice?secret=MFRGGZA"
53+
54+
assert :error == OTPAuth.decompose_uri(uri)
55+
end
56+
57+
test "rejects an empty issuer from URI params" do
58+
uri = "otpauth://totp/alice?secret=MFRGGZA&issuer="
59+
60+
assert :error == OTPAuth.decompose_uri(uri)
61+
end
62+
63+
test "rejects different issuers in prefix and URI params" do
64+
uri = "otpauth://totp/Acme:alice?secret=MFRGGZA&issuer=Corp"
65+
66+
assert :error == OTPAuth.decompose_uri(uri)
67+
end
68+
69+
test "rejects URI with wrong scheme or host" do
70+
uri = "otpauth://hotp/Acme:alice?secret=MFRGGZA&issuer=Corp"
71+
72+
assert :error == OTPAuth.decompose_uri(uri)
73+
end
74+
75+
test "rejects URI if issuer contains ':'" do
76+
uri = "otpauth://hotp/alice?secret=MFRGGZA&issuer=Acme:Corp"
77+
78+
assert :error == OTPAuth.decompose_uri(uri)
79+
end
80+
81+
test "rejects URI if label contains ':'" do
82+
uri = "otpauth://hotp/Acme:Corp:alice?secret=MFRGGZA"
83+
84+
assert :error == OTPAuth.decompose_uri(uri)
85+
end
86+
87+
test "decode an URI containing encoded label" do
88+
uri =
89+
"otpauth://totp/T%C3%A9l%C3%A9com%20Paris:user?secret=MFRGGZA&issuer=T%C3%A9l%C3%A9com%20Paris"
90+
91+
assert {:ok, "abcd", "user", %{"issuer" => "Télécom Paris"}} ==
92+
OTPAuth.decompose_uri(uri)
93+
end
94+
end
95+
end

test/test_helper.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ExUnit.start()

0 commit comments

Comments
 (0)