From 638ef049e9f39ae07a2ae0b71ed4cf51b9d319bb Mon Sep 17 00:00:00 2001 From: Francois Marier Date: Tue, 14 Apr 2026 11:44:19 -0700 Subject: [PATCH] Add optional support for FreeOTP's image parameter https://github.com/freeotp/freeotp-android/blob/master/URI.md#image --- hotp/hotp.go | 6 ++++++ hotp/hotp_test.go | 10 ++++++++++ otp.go | 7 +++++++ otp_test.go | 12 ++++++++++++ totp/totp.go | 6 ++++++ totp/totp_test.go | 10 ++++++++++ 6 files changed, 51 insertions(+) diff --git a/hotp/hotp.go b/hotp/hotp.go index bc23b66..6bda961 100644 --- a/hotp/hotp.go +++ b/hotp/hotp.go @@ -179,6 +179,8 @@ type GenerateOpts struct { Digits otp.Digits // Algorithm to use for HMAC. Defaults to SHA1. Algorithm otp.Algorithm + // Optional image to display next to the code in supported authenticators. + ImageURL string // Reader to use for generating HOTP Key. Rand io.Reader } @@ -226,6 +228,10 @@ func Generate(opts GenerateOpts) (*otp.Key, error) { v.Set("algorithm", opts.Algorithm.String()) v.Set("digits", opts.Digits.String()) + if opts.ImageURL != "" { + v.Set("image", opts.ImageURL) + } + u := url.URL{ Scheme: "otpauth", Host: "hotp", diff --git a/hotp/hotp_test.go b/hotp/hotp_test.go index ddb760a..d034e84 100644 --- a/hotp/hotp_test.go +++ b/hotp/hotp_test.go @@ -152,6 +152,7 @@ func TestGenerate(t *testing.T) { require.Equal(t, "SnakeOil", k.Issuer(), "Extracting Issuer") require.Equal(t, "alice@example.com", k.AccountName(), "Extracting Account Name") require.Equal(t, 16, len(k.Secret()), "Secret is 16 bytes long as base32.") + require.NotContains(t, k.String(), "image=") k, err = Generate(GenerateOpts{ Issuer: "Snake Oil", @@ -199,4 +200,13 @@ func TestGenerate(t *testing.T) { sec, err := b32NoPadding.DecodeString(k.Secret()) require.NoError(t, err, "Secret was not valid base32") require.Equal(t, sec, []byte("helloworld"), "Specified Secret was not kept") + + k, err = Generate(GenerateOpts{ + Issuer: "SnakeOil", + AccountName: "alice@example.com", + ImageURL: "https://example.com/icon.png?size=200", + }) + require.NoError(t, err, "generate HOTP with image") + require.Equal(t, "https://example.com/icon.png?size=200", k.ImageURL()) + require.Contains(t, k.String(), "image=https:%2F%2Fexample.com%2Ficon.png%3Fsize=200") } diff --git a/otp.go b/otp.go index 6d2ea63..c93c42d 100644 --- a/otp.go +++ b/otp.go @@ -191,6 +191,13 @@ func (k *Key) Encoder() Encoder { } } +// ImageURL returns an optional image URL for this Key. +// See https://github.com/freeotp/freeotp-android/blob/master/URI.md#image +func (k *Key) ImageURL() string { + q := k.url.Query() + return q.Get("image") +} + // URL returns the OTP URL as a string func (k *Key) URL() string { return k.url.String() diff --git a/otp_test.go b/otp_test.go index 8ad33fe..a1873bf 100644 --- a/otp_test.go +++ b/otp_test.go @@ -48,6 +48,18 @@ func TestKeyNoIssuer(t *testing.T) { require.Equal(t, "alice@google.com", k.AccountName(), "Extracting Account Name") } +func TestKeyImageURL(t *testing.T) { + k, err := NewKeyFromURL(`otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&image=https%3A%2F%2Fexample.com%2Ficon.png%3Fsize%3D200`) + require.NoError(t, err) + require.Equal(t, "https://example.com/icon.png?size=200", k.ImageURL()) +} + +func TestKeyImageURLMissing(t *testing.T) { + k, err := NewKeyFromURL(`otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example`) + require.NoError(t, err) + require.Equal(t, "", k.ImageURL()) +} + func TestKeyWithNewLine(t *testing.T) { w, err := NewKeyFromURL(`otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP `) diff --git a/totp/totp.go b/totp/totp.go index f45508a..73429d9 100644 --- a/totp/totp.go +++ b/totp/totp.go @@ -147,6 +147,8 @@ type GenerateOpts struct { Digits otp.Digits // Algorithm to use for HMAC. Defaults to SHA1. Algorithm otp.Algorithm + // Optional image to display next to the code in supported authenticators. + ImageURL string // Reader to use for generating TOTP Key. Rand io.Reader } @@ -199,6 +201,10 @@ func Generate(opts GenerateOpts) (*otp.Key, error) { v.Set("algorithm", opts.Algorithm.String()) v.Set("digits", opts.Digits.String()) + if opts.ImageURL != "" { + v.Set("image", opts.ImageURL) + } + u := url.URL{ Scheme: "otpauth", Host: "totp", diff --git a/totp/totp_test.go b/totp/totp_test.go index f56e36a..773f283 100644 --- a/totp/totp_test.go +++ b/totp/totp_test.go @@ -127,6 +127,7 @@ func TestGenerate(t *testing.T) { require.Equal(t, "SnakeOil", k.Issuer(), "Extracting Issuer") require.Equal(t, "alice@example.com", k.AccountName(), "Extracting Account Name") require.Equal(t, 32, len(k.Secret()), "Secret is 32 bytes long as base32.") + require.NotContains(t, k.String(), "image=") k, err = Generate(GenerateOpts{ Issuer: "Snake Oil", @@ -160,6 +161,15 @@ func TestGenerate(t *testing.T) { sec, err := b32NoPadding.DecodeString(k.Secret()) require.NoError(t, err, "Secret wa not valid base32") require.Equal(t, sec, []byte("helloworld"), "Specified Secret was not kept") + + k, err = Generate(GenerateOpts{ + Issuer: "SnakeOil", + AccountName: "alice@example.com", + ImageURL: "https://example.com/icon.png?size=200", + }) + require.NoError(t, err, "generate TOTP with image") + require.Equal(t, "https://example.com/icon.png?size=200", k.ImageURL()) + require.Contains(t, k.String(), "image=https:%2F%2Fexample.com%2Ficon.png%3Fsize=200") } func TestGoogleLowerCaseSecret(t *testing.T) {