diff --git a/.gitignore b/.gitignore
index be24e66..6e4d3d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
.env
.idea
+config.json
config.local.json
delme.*
*.prod.yml
diff --git a/LICENSE b/LICENSE
index 05f2ccb..16ab856 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright 2022 Micah Parks
+ Copyright 2024 Micah Parks
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index e598e0b..e1b4bf1 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,59 @@
# magiclinksdev
-You can find the documentation for this project on the [docs site](https://docs.magiclinks.dev). This site contains
-resources for implementing a client and self-hosting the project.
-
-You can find the SaaS landing page at [https://magiclinks.dev](https://magiclinks.dev). Use of the SaaS platform is not
-required, but it's very inexpensive and may be cheaper than deploying yourself.
-
-# Getting started
-
-The **magiclinksdev** project is an authentication service that uses magic links to authenticate users. A typical use
-case would involve sending a magic link to a user via email. After the user clicks the link, a new authenticated session
-is created for that user. Sometimes **magiclinksdev** is abbreviated as "mld".
-
-## About
-
-This project is a magic link authentication service. It serves use cases like:
+The **magiclinksdev** project is an authentication service for magic link and One-Time Password (OTP) use cases. There
+is built-in email support through Amazon SES and SendGrid.
+Use cases include:
* Sign up
* Log in
* Password resets
* Email verification
* And more authentication use cases
-It can be used to supplement password authentication or replace it entirely.
+This project can be used to supplement password authentication or replace it entirely.
+
+If your project has an alternate secure means of communication, you can use generate magic links and OTPs without
+sending emails. An example would be mobile push notifications.
-A typical use case involves sending a magic link to a user via email. After the user clicks the link, a new
-authenticated session is created for that user.
+## Getting started
+
+To get started implementing a client application that uses **magiclinksdev** for authentication, the recommended path
+is:
+1. Do the [quickstart](https://docs.magiclinks.dev/self-host-quickstart)
+2. Find a [pre-built SDK](https://docs.magiclinks.dev/client-sdk) or [generate one from the formatted API specification](https://docs.magiclinks.dev/client-api-specification#generate-code)
+3. Choose the [magic link](https://docs.magiclinks.dev/client-magic-link-workflow) or [OTP](https://docs.magiclinks.dev/client-otp-workflow) workflow
+4. Review the [implementation tips](https://docs.magiclinks.dev/client-implementation-tips) for recommendations and best practices
## Screenshots
-The built-in email template is populated on a per-request basis. It adapts to the device's theme automatically. This
-template was built using [maizzle](https://maizzle.com/).
+The built-in email templates are friendly to mobile and desktop screens. They also adapt to light/dark mode
+automatically. The templates are built using [maizzle](https://maizzle.com/).
-
-
+
+
+
+
+
+
-## Suggested Email Workflow
-
-
-
-## Implementing a client application
+## Suggested Magic Link Workflow
+
-Client applications are programs that use the **magiclinksdev** project to authenticate their users. Check out the
-[**SDKs**](https://docs.magiclinks.dev/sdks) page to get started with an existing SDK. If you can't find an SDK for your
-language, you can use the [**Specification**](https://docs.magiclinks.dev/specification) to implement your own client by
-hand or code generation. To learn more about the client workflow, check out the
-[**Workflow**](https://docs.magiclinks.dev/workflow) page.
+## Suggested OTP Workflow
+
## Self-hosting the service
-The **magiclinksdev** project can be self-hosted. Check out the [**Quickstart**](https://docs.magiclinks.dev/quickstart)
-page to get started in minutes. For reference on configuring your self-hosted instance, check out the
-[**Configuration**](https://docs.magiclinks.dev/configuration).
+The **magiclinksdev** project is open-source and can be self-hosted. Check out the [**Quickstart**](https://docs.magiclinks.dev/self-host-quickstart) page
+to get started in minutes. For reference on configuring your self-hosted instance, check out the
+[**Configuration**](https://docs.magiclinks.dev/self-host-configuration).
## Source code and license
The **magiclinksdev** project is [open source on GitHub](https://github.com/MicahParks/magiclinksdev) and licensed
-under [Apache License 2.0](https://github.com/MicahParks/magiclinksdev/blob/master/LICENSE).
+under [**Apache License 2.0**](https://github.com/MicahParks/magiclinksdev/blob/master/LICENSE).
## Optional SaaS platform
diff --git a/buildDocker.sh b/buildDocker.sh
index 18c498f..95a4769 100644
--- a/buildDocker.sh
+++ b/buildDocker.sh
@@ -7,16 +7,16 @@ if [ "$1" = "push" ]; then
then
exit 1
fi
- docker build --pull --push --file nop.Dockerfile --tag micahparks/magiclinksdevmulti .
+ docker build --pull --push --file multi.Dockerfile --tag micahparks/magiclinksdevmulti .
docker build --pull --push --file nop.Dockerfile --tag micahparks/magiclinksdevnop .
docker build --pull --push --file ses.Dockerfile --tag micahparks/magiclinksdevses .
docker build --pull --push --file sendgrid.Dockerfile --tag micahparks/magiclinksdevsendgrid .
- docker build --pull --push --file nop.Dockerfile --tag "micahparks/magiclinksdevmulti:$TAG" .
+ docker build --pull --push --file multi.Dockerfile --tag "micahparks/magiclinksdevmulti:$TAG" .
docker build --pull --push --file nop.Dockerfile --tag "micahparks/magiclinksdevnop:$TAG" .
docker build --pull --push --file ses.Dockerfile --tag "micahparks/magiclinksdevses:$TAG" .
docker build --pull --push --file sendgrid.Dockerfile --tag "micahparks/magiclinksdevsendgrid:$TAG" .
else
- docker build --pull --file nop.Dockerfile --tag micahparks/magiclinksdevmulti .
+ docker build --pull --file multi.Dockerfile --tag micahparks/magiclinksdevmulti .
docker build --pull --file nop.Dockerfile --tag micahparks/magiclinksdevnop .
docker build --pull --file ses.Dockerfile --tag micahparks/magiclinksdevses .
docker build --pull --file sendgrid.Dockerfile --tag micahparks/magiclinksdevsendgrid .
diff --git a/client/client.go b/client/client.go
index ec84d23..dc76e6c 100644
--- a/client/client.go
+++ b/client/client.go
@@ -22,8 +22,8 @@ import (
)
const (
- // SaaSBaseURL is the base URL for the SaaS offering. The SaaS offering is optional and the magiclinksdev project
- // can be self-hosted.
+ // SaaSBaseURL is the base URL for the SaaS platform. The SaaS platform is optional and the magiclinksdev project
+ // is open-source and can be self-hosted.
SaaSBaseURL = "https://magiclinks.dev"
// SaaSIss is the iss claim for JWTs in the SaaS offering.
SaaSIss = SaaSBaseURL
@@ -43,7 +43,7 @@ type Options struct {
HTTP *http.Client
}
-// Client is a client for the magiclinksdev project.
+// Client is the official Golang API client for the magiclinksdev project.
type Client struct {
apiKey uuid.UUID
aud uuid.UUID
@@ -54,9 +54,9 @@ type Client struct {
}
// New creates a new magiclinksdev client. The apiKey and aud are tied to the service account being used. The baseURL is
-// the HTTP(S) location of the magiclinksdev deployment. Only use HTTPS in production. For the SaaS offering, use the
+// the HTTP(S) location of the magiclinksdev deployment. Only use HTTPS in production. For the SaaS platform, use the
// SaaSBaseURL constant. The iss is the issuer of the JWTs, which is in the configuration of the magiclinksdev
-// deployment. For the SaaS offering, use the SaaSIss constant. Providing an empty string for the iss will disable
+// deployment. For the SaaS platform, use the SaaSIss constant. Providing an empty string for the iss will disable
// issuer validation.
func New(apiKey, aud uuid.UUID, baseURL, iss string, options Options) (Client, error) {
u, err := url.Parse(baseURL)
@@ -96,8 +96,8 @@ func New(apiKey, aud uuid.UUID, baseURL, iss string, options Options) (Client, e
}
// LocalJWTValidate validates a JWT locally. If the claims argument is not nil, its value will be passed directly to
-// jwt.ParseWithClaims. The claims should be unmarshalled into the provided non-nil pointer after the function call. See
-// the documentation for jwt.ParseWithClaims for more information. Registered JWT claims will be validated regardless if
+// jwt.ParseWithClaims. The claims should be unmarshalled into the claims argument if it is a non-nil pointer. See the
+// documentation for jwt.ParseWithClaims for more information. Registered JWT claims will be validated regardless if
// claims are specified or not.
func (c Client) LocalJWTValidate(token string, claims jwt.Claims) (*jwt.Token, error) {
if c.keyf == nil {
@@ -136,15 +136,6 @@ func (c Client) LocalJWTValidate(token string, claims jwt.Claims) (*jwt.Token, e
return t, nil
}
-// EmailLinkCreate calls the /email-link/create endpoint and returns the appropriate response.
-func (c Client) EmailLinkCreate(ctx context.Context, req model.EmailLinkCreateRequest) (model.EmailLinkCreateResponse, model.Error, error) {
- resp, errResp, err := request[model.EmailLinkCreateRequest, model.EmailLinkCreateResponse](ctx, c, http.StatusCreated, network.PathEmailLinkCreate, req)
- if err != nil {
- return model.EmailLinkCreateResponse{}, errResp, fmt.Errorf("failed to create email link: %w", err)
- }
- return resp, errResp, nil
-}
-
// JWTCreate calls the /jwt/create endpoint and returns the appropriate response.
func (c Client) JWTCreate(ctx context.Context, req model.JWTCreateRequest) (model.JWTCreateResponse, model.Error, error) {
resp, errResp, err := request[model.JWTCreateRequest, model.JWTCreateResponse](ctx, c, http.StatusCreated, network.PathJWTCreate, req)
@@ -165,16 +156,52 @@ func (c Client) JWTValidate(ctx context.Context, req model.JWTValidateRequest) (
return resp, errResp, nil
}
-// LinkCreate calls the /link/create endpoint and returns the appropriate response.
-func (c Client) LinkCreate(ctx context.Context, req model.LinkCreateRequest) (model.LinkCreateResponse, model.Error, error) {
- resp, errResp, err := request[model.LinkCreateRequest, model.LinkCreateResponse](ctx, c, http.StatusCreated, network.PathLinkCreate, req)
+// MagicLinkCreate calls the /magic-link/create endpoint and returns the appropriate response.
+func (c Client) MagicLinkCreate(ctx context.Context, req model.MagicLinkCreateRequest) (model.MagicLinkCreateResponse, model.Error, error) {
+ resp, errResp, err := request[model.MagicLinkCreateRequest, model.MagicLinkCreateResponse](ctx, c, http.StatusCreated, network.PathMagicLinkCreate, req)
+ if err != nil {
+ return model.MagicLinkCreateResponse{}, errResp, fmt.Errorf("failed to create link: %w", err)
+ }
+ return resp, errResp, nil
+}
+
+// MagicLinkEmailCreate calls the /magic-link-email/create endpoint and returns the appropriate response.
+func (c Client) MagicLinkEmailCreate(ctx context.Context, req model.MagicLinkEmailCreateRequest) (model.MagicLinkEmailCreateResponse, model.Error, error) {
+ resp, errResp, err := request[model.MagicLinkEmailCreateRequest, model.MagicLinkEmailCreateResponse](ctx, c, http.StatusCreated, network.PathMagicLinkEmailCreate, req)
+ if err != nil {
+ return model.MagicLinkEmailCreateResponse{}, errResp, fmt.Errorf("failed to create email link: %w", err)
+ }
+ return resp, errResp, nil
+}
+
+// OTPCreate calls the /otp/create endpoint and returns the appropriate response.
+func (c Client) OTPCreate(ctx context.Context, req model.OTPCreateRequest) (model.OTPCreateResponse, model.Error, error) {
+ resp, errResp, err := request[model.OTPCreateRequest, model.OTPCreateResponse](ctx, c, http.StatusCreated, network.PathOTPCreate, req)
+ if err != nil {
+ return model.OTPCreateResponse{}, errResp, fmt.Errorf("failed to create OTP: %w", err)
+ }
+ return resp, errResp, nil
+}
+
+// OTPValidate calls the /otp/validate endpoint and returns the appropriate response.
+func (c Client) OTPValidate(ctx context.Context, req model.OTPValidateRequest) (model.OTPValidateResponse, model.Error, error) {
+ resp, errResp, err := request[model.OTPValidateRequest, model.OTPValidateResponse](ctx, c, http.StatusOK, network.PathOTPValidate, req)
+ if err != nil {
+ return model.OTPValidateResponse{}, errResp, fmt.Errorf("failed to validate OTP: %w", err)
+ }
+ return resp, errResp, nil
+}
+
+// OTPEmailCreate calls the /otp-email/create endpoint and returns the appropriate response.
+func (c Client) OTPEmailCreate(ctx context.Context, req model.OTPEmailCreateRequest) (model.OTPEmailCreateResponse, model.Error, error) {
+ resp, errResp, err := request[model.OTPEmailCreateRequest, model.OTPEmailCreateResponse](ctx, c, http.StatusCreated, network.PathOTPEmailCreate, req)
if err != nil {
- return model.LinkCreateResponse{}, errResp, fmt.Errorf("failed to create link: %w", err)
+ return model.OTPEmailCreateResponse{}, errResp, fmt.Errorf("failed to create email OTP: %w", err)
}
return resp, errResp, nil
}
-// Ready calls the /ready endpoint. An error is returned if the service is not ready.
+// Ready calls the /ready endpoint. An error is returned if the service is not ready to accept requests.
func (c Client) Ready(ctx context.Context) error {
u, err := c.baseURL.Parse(network.PathReady)
if err != nil {
diff --git a/client/client_test.go b/client/client_test.go
index cea7c0e..4bfdf07 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -39,13 +39,13 @@ var (
_, attackerKey, _ = ed25519.GenerateKey(nil)
redirectPath, _ = url.Parse(mld.DefaultRelativePathRedirect)
- jwtCreateArgs = model.JWTCreateArgs{
- JWTClaims: mldtest.TClaims,
- JWTLifespanSeconds: 5,
+ jwtCreateParams = model.JWTCreateParams{
+ Claims: mldtest.TClaims,
+ LifespanSeconds: 5,
}
- linkArgs = model.LinkCreateArgs{
- JWTCreateArgs: jwtCreateArgs,
- LinkLifespan: 5,
+ linkParams = model.MagicLinkCreateParams{
+ JWTCreateParams: jwtCreateParams,
+ LifespanSeconds: 5,
RedirectQueryKey: magiclink.DefaultRedirectQueryKey,
RedirectURL: "http://example.com",
}
@@ -102,8 +102,8 @@ func TestEmailLinkCreate(t *testing.T) {
ctx := createCtx(t)
c := newClient(ctx, t)
- req := model.EmailLinkCreateRequest{
- EmailArgs: model.EmailLinkCreateArgs{
+ req := model.MagicLinkEmailCreateRequest{
+ MagicLinkEmailCreateParams: model.MagicLinkEmailCreateParams{
ButtonText: "Test button text",
Greeting: "Test greeting",
LogoClickURL: mldtest.LogoClickURL,
@@ -115,9 +115,9 @@ func TestEmailLinkCreate(t *testing.T) {
ToEmail: "customer@example.com",
ToName: "Test name",
},
- LinkArgs: linkArgs,
+ MagicLinkCreateParams: linkParams,
}
- resp, mldErr, err := c.EmailLinkCreate(ctx, req)
+ resp, mldErr, err := c.MagicLinkEmailCreate(ctx, req)
if err != nil {
t.Fatalf("Failed to create email link: %v.", err)
}
@@ -126,7 +126,7 @@ func TestEmailLinkCreate(t *testing.T) {
}
validateMetadata(t, resp.RequestMetadata)
- validateLinkResults(t, resp.EmailLinkCreateResults.LinkCreateResults)
+ validateLinkResults(t, resp.MagicLinkEmailCreateResults.MagicLinkCreateResults)
}
func TestJWTCreate(t *testing.T) {
@@ -179,7 +179,7 @@ func TestJWTValidate(t *testing.T) {
raw := jwtCreateHelper(ctx, t, c)
req := model.JWTValidateRequest{
- JWTValidateArgs: model.JWTValidateArgs{
+ JWTValidateParams: model.JWTValidateParams{
JWT: raw,
},
}
@@ -217,7 +217,7 @@ func TestJWTValidateForged(t *testing.T) {
}
req := model.JWTValidateRequest{
- JWTValidateArgs: model.JWTValidateArgs{
+ JWTValidateParams: model.JWTValidateParams{
JWT: raw,
},
}
@@ -237,10 +237,10 @@ func TestLinkCreate(t *testing.T) {
ctx := createCtx(t)
c := newClient(ctx, t)
- req := model.LinkCreateRequest{
- LinkArgs: linkArgs,
+ req := model.MagicLinkCreateRequest{
+ MagicLinkCreateParams: linkParams,
}
- resp, mldErr, err := c.LinkCreate(ctx, req)
+ resp, mldErr, err := c.MagicLinkCreate(ctx, req)
if err != nil {
t.Fatalf("Failed to create magic link: %v.", err)
}
@@ -249,7 +249,7 @@ func TestLinkCreate(t *testing.T) {
}
validateMetadata(t, resp.RequestMetadata)
- validateLinkResults(t, resp.LinkCreateResults)
+ validateLinkResults(t, resp.MagicLinkCreateResults)
}
func TestServiceAccountCreate(t *testing.T) {
@@ -257,7 +257,7 @@ func TestServiceAccountCreate(t *testing.T) {
c := newClient(ctx, t)
req := model.ServiceAccountCreateRequest{
- CreateServiceAccountArgs: model.ServiceAccountCreateArgs{},
+ ServiceAccountCreateParams: model.ServiceAccountCreateParams{},
}
resp, mldErr, err := c.ServiceAccountCreate(ctx, req)
if err != nil {
@@ -269,16 +269,16 @@ func TestServiceAccountCreate(t *testing.T) {
validateMetadata(t, resp.RequestMetadata)
- if resp.CreateServiceAccountResults.ServiceAccount.Admin {
+ if resp.ServiceAccountCreateResults.ServiceAccount.Admin {
t.Fatalf("Created service account should not be an admin.")
}
- if resp.CreateServiceAccountResults.ServiceAccount.UUID == uuid.Nil {
+ if resp.ServiceAccountCreateResults.ServiceAccount.UUID == uuid.Nil {
t.Fatalf("Created service account should have non-nil UUID.")
}
- if resp.CreateServiceAccountResults.ServiceAccount.Aud == uuid.Nil {
+ if resp.ServiceAccountCreateResults.ServiceAccount.Aud == uuid.Nil {
t.Fatalf("Created service account should have non-nil audience UUID.")
}
- if resp.CreateServiceAccountResults.ServiceAccount.APIKey == uuid.Nil {
+ if resp.ServiceAccountCreateResults.ServiceAccount.APIKey == uuid.Nil {
t.Fatalf("Created service account should have non-nil API key.")
}
}
@@ -291,7 +291,7 @@ func createCtx(t *testing.T) context.Context {
func jwtCreateHelper(ctx context.Context, t *testing.T, c Client) string {
req := model.JWTCreateRequest{
- JWTCreateArgs: jwtCreateArgs,
+ JWTCreateParams: jwtCreateParams,
}
resp, mldErr, err := c.JWTCreate(ctx, req)
if err != nil {
@@ -324,12 +324,12 @@ func jwtCreateHelper(ctx context.Context, t *testing.T, c Client) string {
func newClient(ctx context.Context, t *testing.T) Client {
conf := config.Config{
- AdminConfig: []model.AdminCreateArgs{
+ AdminCreateParams: []model.AdminCreateParams{
{
- APIKey: mldtest.APIKey,
- Aud: mldtest.Aud,
- UUID: mldtest.SAUUID,
- ServiceAccountCreateArgs: model.ServiceAccountCreateArgs{},
+ APIKey: mldtest.APIKey,
+ Aud: mldtest.Aud,
+ UUID: mldtest.SAUUID,
+ ServiceAccountCreateParams: model.ServiceAccountCreateParams{},
},
},
BaseURL: jt.New(baseURL),
@@ -407,7 +407,7 @@ func newClient(ctx context.Context, t *testing.T) Client {
return c
}
-func validateLinkResults(t *testing.T, results model.LinkCreateResults) {
+func validateLinkResults(t *testing.T, results model.MagicLinkCreateResults) {
ml, err := url.Parse(results.MagicLink)
if err != nil {
t.Fatalf("Failed to parse magic link: %v.", err)
diff --git a/cmd/stress/go.mod b/cmd/stress/go.mod
index 4f90781..48cc877 100644
--- a/cmd/stress/go.mod
+++ b/cmd/stress/go.mod
@@ -1,27 +1,31 @@
module github.com/MicahParks/magiclinksdev/cmd/stress
-go 1.21.1
+go 1.22.0
+
+toolchain go1.23.2
require (
- github.com/MicahParks/magiclinksdev v0.4.3
- golang.org/x/sync v0.3.0
+ github.com/MicahParks/magiclinksdev v0.5.1
+ golang.org/x/sync v0.8.0
)
require (
- github.com/MicahParks/jsontype v0.5.0 // indirect
- github.com/MicahParks/jwkset v0.3.1 // indirect
- github.com/MicahParks/keyfunc v1.9.0 // indirect
+ github.com/MicahParks/jsontype v0.6.1 // indirect
+ github.com/MicahParks/jwkset v0.5.20 // indirect
+ github.com/MicahParks/keyfunc/v3 v3.3.5 // indirect
github.com/MicahParks/recaptcha v0.0.5 // indirect
- github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
- github.com/google/uuid v1.3.1 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
- github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
- github.com/jackc/pgx/v5 v5.4.3 // indirect
- github.com/tidwall/gjson v1.16.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.7.1 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
- golang.org/x/crypto v0.13.0 // indirect
- golang.org/x/text v0.13.0 // indirect
- golang.org/x/time v0.3.0 // indirect
+ golang.org/x/crypto v0.28.0 // indirect
+ golang.org/x/mod v0.21.0 // indirect
+ golang.org/x/text v0.19.0 // indirect
+ golang.org/x/time v0.7.0 // indirect
)
diff --git a/cmd/stress/go.sum b/cmd/stress/go.sum
index ec3ddd3..5a63d40 100644
--- a/cmd/stress/go.sum
+++ b/cmd/stress/go.sum
@@ -1,47 +1,46 @@
-github.com/MicahParks/jsontype v0.5.0 h1:O/7LAAbWEe3sPNvwaLINy6eEvgINQlJRNEShyUV5Jrc=
-github.com/MicahParks/jsontype v0.5.0/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y=
-github.com/MicahParks/jwkset v0.3.1 h1:DIVazR/elD8CLWPblrVo610TzovIDYMcvlM4X0UT0vQ=
-github.com/MicahParks/jwkset v0.3.1/go.mod h1:Ob0sxSgMmQZFg4GO59PVBnfm+jtdQ1MJbfZDU90tEwM=
-github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
-github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
-github.com/MicahParks/magiclinksdev v0.4.3 h1:kSnPlAZwW8uzdiE/aC2AcPNs8zcFAklHlzLVlMIdSIo=
-github.com/MicahParks/magiclinksdev v0.4.3/go.mod h1:avz0c4ftuAk6GWtOkBe/yUgAb+MZmUuTF3xPpsC/XJQ=
+github.com/MicahParks/jsontype v0.6.1 h1:yFiDEOgSCDT+Es8k17PYZkvpqbZJ9GxJH2ioeVGvgt0=
+github.com/MicahParks/jsontype v0.6.1/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y=
+github.com/MicahParks/jwkset v0.5.20 h1:gTIKx9AofTqQJ0srd8AL7ty9NeadP5WUXSPOZadTpOI=
+github.com/MicahParks/jwkset v0.5.20/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY=
+github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo=
+github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8=
+github.com/MicahParks/magiclinksdev v0.5.1 h1:EB6h0YP7Qd31ycnr6yZqDjag3eUXKWjGQgkb/QMVOZ0=
+github.com/MicahParks/magiclinksdev v0.5.1/go.mod h1:jwoiNsLu1qMyLEj4hSLD3mMwGPMXmMoyBMTwfhsAV8c=
github.com/MicahParks/recaptcha v0.0.5 h1:RvKq7E1BZJtz5ubSkBun20jXxIsMWt2oZ0ppTJOzX1A=
github.com/MicahParks/recaptcha v0.0.5/go.mod h1:aFv3iZDDs6Pbi6tRpUm8gofaTUnDxOQ27x5KsK0CZwE=
-github.com/aws/aws-sdk-go v1.45.6 h1:Y2isQQBZsnO15dzUQo9YQRThtHgrV200XCH05BRHVJI=
-github.com/aws/aws-sdk-go v1.45.6/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
+github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
-github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
-github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
-github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
-github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
-github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
-github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
+github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
-github.com/sendgrid/sendgrid-go v3.13.0+incompatible h1:HZrzc06/QfBGesY9o3n1lvBrRONA+57rbDRKet7plos=
-github.com/sendgrid/sendgrid-go v3.13.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
+github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw=
+github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg=
-github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
@@ -49,16 +48,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
+golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
+golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/cmd/stress/go.work b/cmd/stress/go.work
new file mode 100644
index 0000000..5fb1412
--- /dev/null
+++ b/cmd/stress/go.work
@@ -0,0 +1,6 @@
+go 1.23.2
+
+use (
+ .
+ ../..
+)
diff --git a/cmd/stress/go.work.sum b/cmd/stress/go.work.sum
new file mode 100644
index 0000000..3ea587c
--- /dev/null
+++ b/cmd/stress/go.work.sum
@@ -0,0 +1,56 @@
+github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
+golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
diff --git a/cmd/stress/link.example.json b/cmd/stress/link.example.json
index 1395e88..1284f2c 100644
--- a/cmd/stress/link.example.json
+++ b/cmd/stress/link.example.json
@@ -1,13 +1,13 @@
{
- "linkArgs": {
- "jwtCreateArgs": {
- "jwtClaims": {
+ "magicLinkCreateParams": {
+ "jwtCreateParams": {
+ "claims": {
"foo": "bar"
},
- "jwtLifespanSeconds": 0
+ "lifespanSeconds": 0
},
- "linkLifespan": 0,
+ "lifespanSeconds": 0,
"redirectQueryKey": "",
- "redirectUrl": "https://jwtdebug.micahparks.com"
+ "redirectURL": "https://jwtdebug.micahparks.com"
}
}
diff --git a/cmd/stress/main.go b/cmd/stress/main.go
index fe902b5..308a028 100644
--- a/cmd/stress/main.go
+++ b/cmd/stress/main.go
@@ -11,15 +11,16 @@ import (
"strings"
"time"
- "github.com/MicahParks/magiclinksdev/mldtest"
"golang.org/x/sync/errgroup"
+ "github.com/MicahParks/magiclinksdev/mldtest"
+
mld "github.com/MicahParks/magiclinksdev"
"github.com/MicahParks/magiclinksdev/network"
"github.com/MicahParks/magiclinksdev/network/middleware"
)
-//go:embed link.prod.json
+//go:embed link.example.json
var linkJSON []byte
func main() {
@@ -35,7 +36,7 @@ func main() {
)
exit(ctx, l, time.Now(), 1)
}
- u, err = u.Parse("/api/v1/" + network.PathLinkCreate)
+ u, err = u.Parse("/api/v2/" + network.PathMagicLinkCreate)
if err != nil {
l.ErrorContext(ctx, "Failed to parse URL.",
mld.LogErr, err,
diff --git a/compose.yaml b/compose.yaml
index 2ca0caf..f14f6cd 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -1,4 +1,3 @@
-version: "3.8"
services:
magiclinksdev:
image: "micahparks/magiclinksdevnop"
diff --git a/config.quickstart.json b/config.quickstart.json
index 193f20d..bd813db 100644
--- a/config.quickstart.json
+++ b/config.quickstart.json
@@ -1,14 +1,14 @@
{
"server": {
- "adminConfig": [
+ "adminCreateParams": [
{
"apiKey": "40084740-0bc3-455d-b298-e23a31561580",
"aud": "ad9e9d84-92ea-4f07-bac9-5d898d59c83b",
"uuid": "1e079d6d-a8b9-4065-aa8d-86906accd211",
- "serviceAccountCreateArgs": {}
+ "serviceAccountCreateParams": {}
}
],
- "baseURL": "http://localhost:8080/api/v1/",
+ "baseURL": "http://localhost:8080/api/v2/",
"iss": "http://localhost:8080"
},
"storage": {
diff --git a/config.test.json b/config.test.json
index 407a213..c241f92 100644
--- a/config.test.json
+++ b/config.test.json
@@ -9,15 +9,15 @@
"refillRate": 0
},
"server": {
- "adminConfig": [
+ "adminCreateParams": [
{
"apiKey": "40084740-0bc3-455d-b298-e23a31561580",
"aud": "ad9e9d84-92ea-4f07-bac9-5d898d59c83b",
"uuid": "1e079d6d-a8b9-4065-aa8d-86906accd211",
- "serviceAccountCreateArgs": {}
+ "serviceAccountCreateParams": {}
}
],
- "baseURL": "http://localtest/api/v1/",
+ "baseURL": "http://localtest/api/v2/",
"iss": "testIssuer",
"jwks": {
"ignoreDefault": false
diff --git a/config/config.go b/config/config.go
index 85249c1..9f5049b 100644
--- a/config/config.go
+++ b/config/config.go
@@ -24,7 +24,7 @@ type LogLevel string
// Config is the configuration for the magiclinksdev server.
type Config struct {
- AdminConfig []model.AdminCreateArgs `json:"adminConfig"`
+ AdminCreateParams []model.AdminCreateParams `json:"adminCreateParams"`
BaseURL *jt.JSONType[*url.URL] `json:"baseURL"`
Iss string `json:"iss"`
JWKS JWKS `json:"jwks"`
diff --git a/constants.go b/constants.go
index c2e4d49..0902b6a 100644
--- a/constants.go
+++ b/constants.go
@@ -1,8 +1,15 @@
package magiclinksdev
+import (
+ "errors"
+ "time"
+)
+
const (
// ContentTypeJSON is the content type for JSON.
ContentTypeJSON = "application/json"
+ // DefaultOTPLength is the default length for OTPs.
+ DefaultOTPLength = 6
// DefaultRelativePathRedirect is the default relative path for redirecting.
DefaultRelativePathRedirect = "redirect"
// HeaderContentType is the content type header.
@@ -15,6 +22,8 @@ const (
LogRequestBody = "requestBody"
// LogResponseBody is key for logging the response body.
LogResponseBody = "responseBody"
+ // Over250Years is the maximum duration for this project. Restriction derived from Golang's time.Duration.
+ Over250Years = 250 * 366 * 24 * time.Hour
// ResponseInternalServerError is the response for internal server errors.
ResponseInternalServerError = "Internal server error."
// ResponseTooManyRequests is the response for too many requests.
@@ -23,6 +32,10 @@ const (
ResponseUnauthorized = "Unauthorized."
)
+var (
+ ErrParams = errors.New("invalid parameters")
+)
+
func Ptr[T any](v T) *T {
return &v
}
diff --git a/email/html.gohtml b/email/html.gohtml
deleted file mode 100644
index 5cce72b..0000000
--- a/email/html.gohtml
+++ /dev/null
@@ -1,166 +0,0 @@
-{{- /*gotype: github.com/MicahParks/magiclinksdev/email.TemplateData*/ -}}
-
-
-
-
-
-
-
-
-
- {{.Meta.MSOHead}}
- {{.Meta.HTMLTitle}}
-
-
-
-
- {{.Meta.HTMLInstruction}}
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
- ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
-
-
-
-
-
-
- {{- if .LogoImageURL -}}
-
-
-
-
-
-
-
- {{- end -}}
-
-
-
-
-
- {{if .Greeting}}{{.Greeting}}
{{end}}
-
- {{.Title}}
-
- {{if .Subtitle}}{{.Subtitle}}
{{end}}
-
-
- {{- if .ReCATPTCHA}}
-
- This service is protected by reCAPTCHA and the Google
- Privacy Policy and
- Terms of Service apply.
-
- {{- end}}
-
- Never forward this email or share its contents with anyone.
-
- This link expires in {{.Expiration}}.
-
-
-
-
-
-
-
-
- Powered by
-
- magiclinks.dev
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/email/interface.go b/email/interface.go
index eaf688e..d403ecc 100644
--- a/email/interface.go
+++ b/email/interface.go
@@ -11,12 +11,13 @@ var ErrProvider = errors.New("error with email provider")
// Provider is the interface for an email provider.
type Provider interface {
- Send(ctx context.Context, e Email) error
+ SendMagicLink(ctx context.Context, e Email) error
+ SendOTP(ctx context.Context, e Email) error
}
// Email is the model for an email.
type Email struct {
Subject string
- TemplateData TemplateData
+ TemplateData any
To *mail.Address
}
diff --git a/email/magic_link_html.gohtml b/email/magic_link_html.gohtml
new file mode 100644
index 0000000..dbb5177
--- /dev/null
+++ b/email/magic_link_html.gohtml
@@ -0,0 +1,128 @@
+{{- /*gotype: github.com/MicahParks/magiclinksdev/email.MagicLinkTemplateData*/ -}}
+
+
+
+
+
+
+
+
+
+ {{.Meta.MSOHead}}
+ {{.Meta.HTMLTitle}}
+
+
+
+
+ {{.Meta.HTMLInstruction}}
+ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
+
+
+
+
+
+
+ {{- if .LogoImageURL}}
+
+ {{- end}}
+
+
+
+ {{- if .Greeting}}
+
+ {{.Greeting}}
+
+ {{- end}}
+
+ {{.Title}}
+
+ {{- if .Subtitle}}
+
+ {{.Subtitle}}
+
+ {{- end}}
+
+
+
+
+ This link expires in {{.Expiration}}.
+
+
+ Support will never ask for anything in this email.
+
+
+ Never forward this email or share its contents with anyone.
+
+ {{- if .ReCATPTCHA}}
+
+ This service is protected by reCAPTCHA and the Google
+ Privacy Policy and
+ Terms of Service apply.
+
+ {{- end}}
+
+
+
+
+
+
+
+
+ Powered by
+ magiclinks.dev
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/email/text.gotxt b/email/magic_link_text.gotxt
similarity index 75%
rename from email/text.gotxt
rename to email/magic_link_text.gotxt
index faa21cb..660967b 100644
--- a/email/text.gotxt
+++ b/email/magic_link_text.gotxt
@@ -1,16 +1,17 @@
-{{- /*gotype: github.com/MicahParks/magiclinksdev/email.TemplateData*/ -}}
+{{- /*gotype: github.com/MicahParks/magiclinksdev/email.MagicLinkTemplateData*/ -}}
{{if .Greeting}}{{.Greeting}}{{end -}}
{{.Title}}
{{if .Subtitle}}{{.Subtitle}}{{end -}}
{{.MagicLink}}
-Never forward this email or share its contents with anyone.
This link expires in {{.Expiration}}.
-
-Powered by magiclinks.dev
+Support will never ask for anything in this email.
+Never forward this email or share its contents with anyone.
{{if .ReCATPTCHA}}
This service is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
https://policies.google.com/privacy
https://policies.google.com/terms
{{end -}}
+
+Powered by magiclinks.dev
diff --git a/email/multi.go b/email/multi.go
index f2c1ba6..4886f06 100644
--- a/email/multi.go
+++ b/email/multi.go
@@ -34,11 +34,27 @@ func NewMultiProvider(providers []Provider, options MultiProviderOptions) (Provi
return m, nil
}
-// Send sends an email using the multiple email provider.
-func (m multiProvider) Send(ctx context.Context, e Email) error {
+func (m multiProvider) SendMagicLink(ctx context.Context, e Email) error {
combinedErr := fmt.Errorf("%w: no providers were able to send the email", ErrProvider)
for _, p := range m.providers {
- err := p.Send(ctx, e)
+ err := p.SendMagicLink(ctx, e)
+ if err == nil {
+ return nil
+ }
+ m.logger.ErrorContext(ctx, "Failed to send email with using multi-provider. Attempting with next provider.",
+ mld.LogErr, err,
+ )
+ combinedErr = fmt.Errorf("%w: %w", combinedErr, err)
+ }
+ m.logger.ErrorContext(ctx, "Failed to send email with using multi-provider. No providers were able to send the email.",
+ mld.LogErr, combinedErr,
+ )
+ return combinedErr
+}
+func (m multiProvider) SendOTP(ctx context.Context, e Email) error {
+ combinedErr := fmt.Errorf("%w: no providers were able to send the email", ErrProvider)
+ for _, p := range m.providers {
+ err := p.SendOTP(ctx, e)
if err == nil {
return nil
}
diff --git a/email/otp_html.gohtml b/email/otp_html.gohtml
new file mode 100644
index 0000000..6989584
--- /dev/null
+++ b/email/otp_html.gohtml
@@ -0,0 +1,110 @@
+{{- /*gotype: github.com/MicahParks/magiclinksdev/email.OTPTemplateData*/ -}}
+
+
+
+
+
+
+
+
+
+ {{.Meta.MSOHead}}
+ {{.Meta.HTMLTitle}}
+
+
+
+
+ {{.Meta.HTMLInstruction}}
+ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
+
+
+
+
+
+
+ {{- if .LogoImageURL}}
+
+ {{- end}}
+
+
+
+ {{- if .Greeting}}
+
+ {{.Greeting}}
+
+ {{- end}}
+
+ {{.Title}}
+
+ {{- if .Subtitle}}
+
+ {{.Subtitle}}
+
+ {{- end}}
+
+
+
+
+ This OTP expires in {{.Expiration}}.
+
+
+ Support will never ask for anything in this email.
+
+
+ Never forward this email or share its contents with anyone.
+
+
+
+
+
+
+
+
+
+ Powered by
+ magiclinks.dev
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/email/otp_text.gotxt b/email/otp_text.gotxt
new file mode 100644
index 0000000..a1d3d6c
--- /dev/null
+++ b/email/otp_text.gotxt
@@ -0,0 +1,14 @@
+{{- /*gotype: github.com/MicahParks/magiclinksdev/email.OTPTemplateData*/ -}}
+{{if .Greeting}}{{.Greeting}}{{end -}}
+
+{{.Title}}
+{{if .Subtitle}}{{.Subtitle}}{{end -}}
+
+Your One-Time Password (OTP) is:
+{{.OTP}}
+
+This OTP expires in {{.Expiration}}.
+Support will never ask for anything in this email.
+Never forward this email or share its contents with anyone.
+
+Powered by magiclinks.dev
diff --git a/email/sendgrid/sendgrid.go b/email/sendgrid/sendgrid.go
index 921c477..555aed8 100644
--- a/email/sendgrid/sendgrid.go
+++ b/email/sendgrid/sendgrid.go
@@ -34,35 +34,47 @@ func (s Config) DefaultsAndValidate() (Config, error) {
}
type sendGrid struct {
- client *sendgrid.Client
- from *netMail.Address
- htmlTmpl *template.Template
- textTmpl *textTemplate.Template
+ client *sendgrid.Client
+ from *netMail.Address
+ magicLinkHTMLTmpl *template.Template
+ magicLinkTxtTmpl *textTemplate.Template
+ oTPHTMLTmpl *template.Template
+ oTPTxtTmpl *textTemplate.Template
}
// NewProvider creates a new SendGrid email provider.
func NewProvider(conf Config) (email.Provider, error) {
client := sendgrid.NewSendClient(conf.APIKey)
- htmlTmpl := template.Must(template.New("").Parse(email.HTMLTemplate))
- textTmpl := textTemplate.Must(textTemplate.New("").Parse(email.TextTemplate))
+ magicLinkHTMLTmpl := template.Must(template.New("").Parse(email.MagicLinkHTMLTemplate))
+ magicLinkTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.MagicLinkTextTemplate))
+ otpHTMLTmpl := template.Must(template.New("").Parse(email.OTPHTMLTemplate))
+ otpTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.OTPTextTemplate))
s := sendGrid{
- client: client,
- from: conf.FromEmail.Get(),
- htmlTmpl: htmlTmpl,
- textTmpl: textTmpl,
+ client: client,
+ from: conf.FromEmail.Get(),
+ magicLinkHTMLTmpl: magicLinkHTMLTmpl,
+ magicLinkTxtTmpl: magicLinkTxtTmpl,
+ oTPHTMLTmpl: otpHTMLTmpl,
+ oTPTxtTmpl: otpTxtTmpl,
}
return s, nil
}
-// Send implements the email.Provider interface.
-func (s sendGrid) Send(ctx context.Context, e email.Email) error {
+func (s sendGrid) SendMagicLink(ctx context.Context, e email.Email) error {
+ return s.sendEmail(ctx, e, s.magicLinkHTMLTmpl, s.magicLinkTxtTmpl)
+}
+func (s sendGrid) SendOTP(ctx context.Context, e email.Email) error {
+ return s.sendEmail(ctx, e, s.oTPHTMLTmpl, s.oTPTxtTmpl)
+}
+
+func (s sendGrid) sendEmail(ctx context.Context, e email.Email, htmlTmpl *template.Template, txtTmpl *textTemplate.Template) error {
htmlBuf := bytes.NewBuffer(nil)
- err := s.htmlTmpl.Execute(htmlBuf, e.TemplateData)
+ err := htmlTmpl.Execute(htmlBuf, e.TemplateData)
if err != nil {
return fmt.Errorf("failed to execute template for HTML email: %w", err)
}
textBuf := bytes.NewBuffer(nil)
- err = s.textTmpl.Execute(textBuf, e.TemplateData)
+ err = txtTmpl.Execute(textBuf, e.TemplateData)
if err != nil {
return fmt.Errorf("failed to execute template for text email: %w", err)
}
diff --git a/email/ses/ses.go b/email/ses/ses.go
index f0f8eee..848d364 100644
--- a/email/ses/ses.go
+++ b/email/ses/ses.go
@@ -54,10 +54,12 @@ func (c Config) DefaultsAndValidate() (Config, error) {
// SES is an email provider that uses AWS SES.
type SES struct {
- from *netMail.Address
- htmlTmpl *template.Template
- ses *ses.SES
- textTmpl *textTemplate.Template
+ from *netMail.Address
+ magicLinkHTMLTmpl *template.Template
+ magicLinkTxtTmpl *textTemplate.Template
+ oTPHTMLTmpl *template.Template
+ oTPTxtTmpl *textTemplate.Template
+ ses *ses.SES
}
// NewProvider creates a new SES provider. It will create an AWS session using the provided configuration.
@@ -82,26 +84,36 @@ func NewProvider(conf Config) (SES, error) {
// NewProviderInitialized creates a new SES provider with an initialized configuration.
func NewProviderInitialized(conf InitializedConfig, svc *ses.SES) (SES, error) {
- htmlTmpl := template.Must(template.New("").Parse(email.HTMLTemplate))
- textTmpl := textTemplate.Must(textTemplate.New("").Parse(email.TextTemplate))
+ magicLinkHTMLTmpl := template.Must(template.New("").Parse(email.MagicLinkHTMLTemplate))
+ magicLinkTxtTML := textTemplate.Must(textTemplate.New("").Parse(email.MagicLinkTextTemplate))
+ otpHTMLTmpl := template.Must(template.New("").Parse(email.OTPHTMLTemplate))
+ otpTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.OTPTextTemplate))
s := SES{
- from: conf.FromEmail,
- htmlTmpl: htmlTmpl,
- ses: svc,
- textTmpl: textTmpl,
+ from: conf.FromEmail,
+ magicLinkHTMLTmpl: magicLinkHTMLTmpl,
+ magicLinkTxtTmpl: magicLinkTxtTML,
+ oTPHTMLTmpl: otpHTMLTmpl,
+ oTPTxtTmpl: otpTxtTmpl,
+ ses: svc,
}
return s, nil
}
-// Send implements the email.Provider interface.
-func (s SES) Send(ctx context.Context, e email.Email) error {
+func (s SES) SendMagicLink(ctx context.Context, e email.Email) error {
+ return s.sendEmail(ctx, e, s.magicLinkHTMLTmpl, s.magicLinkTxtTmpl)
+}
+func (s SES) SendOTP(ctx context.Context, e email.Email) error {
+ return s.sendEmail(ctx, e, s.oTPHTMLTmpl, s.oTPTxtTmpl)
+}
+
+func (s SES) sendEmail(ctx context.Context, e email.Email, htmlTmpl *template.Template, txtTmpl *textTemplate.Template) error {
htmlBuf := bytes.NewBuffer(nil)
- err := s.htmlTmpl.Execute(htmlBuf, e.TemplateData)
+ err := htmlTmpl.Execute(htmlBuf, e.TemplateData)
if err != nil {
return fmt.Errorf("failed to execute template for HTML email: %w", err)
}
textBuf := bytes.NewBuffer(nil)
- err = s.textTmpl.Execute(textBuf, e.TemplateData)
+ err = txtTmpl.Execute(textBuf, e.TemplateData)
if err != nil {
return fmt.Errorf("failed to execute template for text email: %w", err)
}
diff --git a/email/template.go b/email/template.go
index b2d9358..6eecb1d 100644
--- a/email/template.go
+++ b/email/template.go
@@ -8,14 +8,14 @@ import (
const (
// MSOButtonStop is the HTML to stop MSO button spacing.
MSOButtonStop = ``
+
+ `
// MSOButtonStart is the HTML to start MSO button spacing.
MSOButtonStart = ``
+
+ `
// MSOHead is the HTML to start MSO head.
- MSOHead = ``
)
-// HTMLTemplate is the HTML template for the email.
+// MagicLinkHTMLTemplate is the HTML template for the magic link email.
//
-//go:embed html.gohtml
-var HTMLTemplate string
+//go:embed magic_link_html.gohtml
+var MagicLinkHTMLTemplate string
-// TextTemplate is the text template for the email.
+// MagicLinkTextTemplate is the text template for the magic link email.
//
-//go:embed text.gotxt
-var TextTemplate string
+//go:embed magic_link_text.gotxt
+var MagicLinkTextTemplate string
-// TemplateData is the data for the email templates.
-type TemplateData struct {
+// MagicLinkTemplateData is the data for the magic link email template.
+type MagicLinkTemplateData struct {
ButtonText string
Expiration string
Greeting string
@@ -54,6 +54,30 @@ type TemplateData struct {
ReCATPTCHA bool
}
+// OTPHTMLTemplate is the HTML template for the OTP email.
+//
+//go:embed otp_html.gohtml
+var OTPHTMLTemplate string
+
+// OTPTextTemplate is the text template for the OTP email.
+//
+//go:embed otp_text.gotxt
+var OTPTextTemplate string
+
+// OTPTemplateData is the data for the OTP email template.
+type OTPTemplateData struct {
+ Expiration string
+ Greeting string
+ MagicLink string
+ Meta TemplateMetadata
+ OTP string
+ Subtitle string
+ Title string
+ LogoImageURL string
+ LogoClickURL string
+ LogoAltText string
+}
+
// TemplateMetadata contains non-configurable metadata for the email templates.
type TemplateMetadata struct {
HTMLInstruction string
diff --git a/examples/client/README.md b/examples/client/README.md
index 54f6864..ac63370 100644
--- a/examples/client/README.md
+++ b/examples/client/README.md
@@ -1 +1,2 @@
-These examples require a running server.
\ No newline at end of file
+For instructions getting started with these examples, please refer to
+the [documentation website quickstart guide](https://docs.magiclinks.dev/self-host-quickstart)
diff --git a/examples/client/jwt_create/main.go b/examples/client/jwt_create/main.go
new file mode 100644
index 0000000..4659084
--- /dev/null
+++ b/examples/client/jwt_create/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "os"
+ "time"
+
+ mld "github.com/MicahParks/magiclinksdev"
+ "github.com/MicahParks/magiclinksdev/client"
+ "github.com/MicahParks/magiclinksdev/mldtest"
+ "github.com/MicahParks/magiclinksdev/model"
+)
+
+func main() {
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ logger := slog.Default()
+
+ c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{})
+ if err != nil {
+ logger.Error("Failed to create client.",
+ mld.LogErr, err,
+ )
+ }
+
+ req := model.JWTCreateRequest{
+ JWTCreateParams: model.JWTCreateParams{
+ Claims: map[string]string{
+ "foo": "bar",
+ },
+ },
+ }
+ resp, mldErr, err := c.JWTCreate(ctx, req)
+ if err != nil {
+ if mldErr.Code != 0 {
+ logger = logger.With(
+ "code", mldErr.Code,
+ "message", mldErr.Message,
+ "requestUUID", mldErr.RequestMetadata.UUID,
+ )
+ }
+ logger.Error("Failed to create JWT.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to marshal response.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ println(string(data))
+}
diff --git a/examples/client/jwt_validate/main.go b/examples/client/jwt_validate/main.go
index 2a707b8..bcfda97 100644
--- a/examples/client/jwt_validate/main.go
+++ b/examples/client/jwt_validate/main.go
@@ -26,6 +26,7 @@ func main() {
logger.Error("Failed to validate JWT. This is normal for the default example.",
mld.LogErr, err,
)
+ return
}
logger.Info("JWT is valid.",
diff --git a/examples/client/send_link/main.go b/examples/client/magic_link_create/main.go
similarity index 83%
rename from examples/client/send_link/main.go
rename to examples/client/magic_link_create/main.go
index 4348076..f75037a 100644
--- a/examples/client/send_link/main.go
+++ b/examples/client/magic_link_create/main.go
@@ -36,18 +36,18 @@ func main() {
os.Exit(1)
}
- req := model.LinkCreateRequest{
- LinkArgs: model.LinkCreateArgs{
- JWTCreateArgs: model.JWTCreateArgs{
- JWTClaims: claims,
- JWTLifespanSeconds: 5,
+ req := model.MagicLinkCreateRequest{
+ MagicLinkCreateParams: model.MagicLinkCreateParams{
+ JWTCreateParams: model.JWTCreateParams{
+ Claims: claims,
+ LifespanSeconds: 5,
},
- LinkLifespan: 100,
+ LifespanSeconds: 100,
RedirectQueryKey: "",
RedirectURL: "https://jwtdebug.micahparks.com",
},
}
- resp, mldErr, err := c.LinkCreate(ctx, req)
+ resp, mldErr, err := c.MagicLinkCreate(ctx, req)
if err != nil {
if mldErr.Code != 0 {
logger = logger.With(
diff --git a/examples/client/send_email_link/main.go b/examples/client/magic_link_email_create/main.go
similarity index 83%
rename from examples/client/send_email_link/main.go
rename to examples/client/magic_link_email_create/main.go
index fcd7902..f90dfff 100644
--- a/examples/client/send_email_link/main.go
+++ b/examples/client/magic_link_email_create/main.go
@@ -36,8 +36,8 @@ func main() {
os.Exit(1)
}
- req := model.EmailLinkCreateRequest{
- EmailArgs: model.EmailLinkCreateArgs{
+ req := model.MagicLinkEmailCreateRequest{
+ MagicLinkEmailCreateParams: model.MagicLinkEmailCreateParams{
ButtonText: "Log in",
Greeting: "Hello John Doe,",
LogoClickURL: "https://magiclinks.dev",
@@ -49,17 +49,17 @@ func main() {
ToEmail: "johndoe@example.com",
ToName: "John Doe",
},
- LinkArgs: model.LinkCreateArgs{
- JWTCreateArgs: model.JWTCreateArgs{
- JWTClaims: claims,
- JWTLifespanSeconds: 5,
+ MagicLinkCreateParams: model.MagicLinkCreateParams{
+ JWTCreateParams: model.JWTCreateParams{
+ Claims: claims,
+ LifespanSeconds: 5,
},
- LinkLifespan: 60 * 60,
+ LifespanSeconds: 60 * 60,
RedirectQueryKey: "",
RedirectURL: "https://jwtdebug.micahparks.com",
},
}
- resp, mldErr, err := c.EmailLinkCreate(ctx, req)
+ resp, mldErr, err := c.MagicLinkEmailCreate(ctx, req)
if err != nil {
if mldErr.Code != 0 {
logger = logger.With(
diff --git a/examples/client/otp_create/main.go b/examples/client/otp_create/main.go
new file mode 100644
index 0000000..0ebd5f6
--- /dev/null
+++ b/examples/client/otp_create/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "log/slog"
+ "os"
+ "time"
+
+ mld "github.com/MicahParks/magiclinksdev"
+ "github.com/MicahParks/magiclinksdev/client"
+ "github.com/MicahParks/magiclinksdev/mldtest"
+ "github.com/MicahParks/magiclinksdev/model"
+)
+
+func main() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ logger := slog.Default()
+
+ c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{})
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to create client.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ req := model.OTPCreateRequest{
+ OTPCreateParams: model.OTPCreateParams{
+ CharSetNumeric: true,
+ Length: 0,
+ LifespanSeconds: 0,
+ },
+ }
+ resp, mldErr, err := c.OTPCreate(ctx, req)
+ if err != nil {
+ if mldErr.Code != 0 {
+ logger = logger.With(
+ "code", mldErr.Code,
+ "message", mldErr.Message,
+ "requestUUID", mldErr.RequestMetadata.UUID,
+ )
+ }
+ logger.ErrorContext(ctx, "Failed to create OTP.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to marshal response.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ println(string(data))
+}
diff --git a/examples/client/otp_email_create/main.go b/examples/client/otp_email_create/main.go
new file mode 100644
index 0000000..ed527d0
--- /dev/null
+++ b/examples/client/otp_email_create/main.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "log/slog"
+ "os"
+ "time"
+
+ mld "github.com/MicahParks/magiclinksdev"
+ "github.com/MicahParks/magiclinksdev/client"
+ "github.com/MicahParks/magiclinksdev/mldtest"
+ "github.com/MicahParks/magiclinksdev/model"
+)
+
+func main() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ logger := slog.Default()
+
+ c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{})
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to create client.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ req := model.OTPEmailCreateRequest{
+ OTPCreateParams: model.OTPCreateParams{
+ CharSetAlphaLower: false,
+ CharSetAlphaUpper: false,
+ CharSetNumeric: true,
+ Length: 0,
+ LifespanSeconds: 0,
+ },
+ OTPEmailCreateParams: model.OTPEmailCreateParams{
+ Greeting: "Hello John Doe,",
+ LogoClickURL: "https://magiclinks.dev",
+ LogoImageURL: "https://magiclinks.dev/typeface-gray.png",
+ ServiceName: "magiclinks.dev",
+ Subject: "Verify your email - magiclinks.dev",
+ SubTitle: "Use this One-Time Password (OTP) to verify your email address.",
+ Title: "Your OTP is below.",
+ ToEmail: "johndoe@example.com",
+ ToName: "John Doe",
+ },
+ }
+ resp, mldErr, err := c.OTPEmailCreate(ctx, req)
+ if err != nil {
+ if mldErr.Code != 0 {
+ logger = logger.With(
+ "code", mldErr.Code,
+ "message", mldErr.Message,
+ "requestUUID", mldErr.RequestMetadata.UUID,
+ )
+ }
+ logger.ErrorContext(ctx, "Failed to create OTP.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to marshal response.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ println(string(data))
+}
diff --git a/examples/client/otp_validate/main.go b/examples/client/otp_validate/main.go
new file mode 100644
index 0000000..41f5b54
--- /dev/null
+++ b/examples/client/otp_validate/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "flag"
+ "log/slog"
+ "os"
+ "time"
+
+ mld "github.com/MicahParks/magiclinksdev"
+ "github.com/MicahParks/magiclinksdev/client"
+ "github.com/MicahParks/magiclinksdev/mldtest"
+ "github.com/MicahParks/magiclinksdev/model"
+)
+
+func main() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ logger := slog.Default()
+
+ var (
+ id string
+ otp string
+ )
+ flag.StringVar(&id, "id", "", "The ID of the OTP to validate.")
+ flag.StringVar(&otp, "otp", "", "The OTP to validate.")
+ flag.Parse()
+ if id == "" || otp == "" {
+ flag.Usage()
+ os.Exit(1)
+ }
+
+ c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{})
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to create client.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ req := model.OTPValidateRequest{
+ OTPValidateParams: model.OTPValidateParams{
+ ID: id,
+ OTP: otp,
+ },
+ }
+ resp, mldErr, err := c.OTPValidate(ctx, req)
+ if err != nil {
+ if mldErr.Code != 0 {
+ logger = logger.With(
+ "code", mldErr.Code,
+ "message", mldErr.Message,
+ "requestUUID", mldErr.RequestMetadata.UUID,
+ )
+ }
+ logger.ErrorContext(ctx, "Failed to validate OTP.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to marshal response.",
+ mld.LogErr, err,
+ )
+ os.Exit(1)
+ }
+
+ println(string(data))
+}
diff --git a/examples/client/create_service_account/main.go b/examples/client/service_account_create/main.go
similarity index 95%
rename from examples/client/create_service_account/main.go
rename to examples/client/service_account_create/main.go
index 9cee8c8..fec6123 100644
--- a/examples/client/create_service_account/main.go
+++ b/examples/client/service_account_create/main.go
@@ -28,7 +28,7 @@ func main() {
}
req := model.ServiceAccountCreateRequest{
- CreateServiceAccountArgs: model.ServiceAccountCreateArgs{},
+ ServiceAccountCreateParams: model.ServiceAccountCreateParams{},
}
resp, mldErr, err := c.ServiceAccountCreate(ctx, req)
if err != nil {
diff --git a/go.mod b/go.mod
index 79b209a..057fdb4 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
golang.org/x/mod v0.21.0
- golang.org/x/time v0.6.0
+ golang.org/x/time v0.7.0
)
require (
@@ -28,7 +28,7 @@ require (
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
- golang.org/x/crypto v0.27.0 // indirect
+ golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sync v0.8.0 // indirect
- golang.org/x/text v0.18.0 // indirect
+ golang.org/x/text v0.19.0 // indirect
)
diff --git a/go.sum b/go.sum
index 3c8fa75..f4ba9c1 100644
--- a/go.sum
+++ b/go.sum
@@ -48,18 +48,18 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
-golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
+golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
+golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
-golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
-golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
-golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/handle/email_link.go b/handle/email_link.go
deleted file mode 100644
index 03ec32c..0000000
--- a/handle/email_link.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package handle
-
-import (
- "context"
- "fmt"
-
- "github.com/google/uuid"
-
- "github.com/MicahParks/magiclinksdev/config"
- "github.com/MicahParks/magiclinksdev/email"
- "github.com/MicahParks/magiclinksdev/model"
- "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
-)
-
-// HandleEmailLinkCreate handles the email link creation endpoint.
-func (s *Server) HandleEmailLinkCreate(ctx context.Context, req model.ValidEmailLinkCreateRequest) (model.EmailLinkCreateResponse, error) {
- emailArgs := req.EmailArgs
- linkArgs := req.LinkArgs
-
- magicLinkResp, err := s.createLink(ctx, linkArgs)
- if err != nil {
- return model.EmailLinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
- }
-
- meta := email.TemplateMetadata{
- HTMLInstruction: fmt.Sprintf("You've been sent a magic link from %s.", emailArgs.ServiceName),
- HTMLTitle: fmt.Sprintf("Magic link from %s", emailArgs.ServiceName),
- MSOButtonStop: email.MSOButtonStop,
- MSOButtonStart: email.MSOButtonStart,
- MSOHead: email.MSOHead,
- }
- tData := email.TemplateData{
- ButtonText: emailArgs.ButtonText,
- Expiration: linkArgs.LinkLifespan.String(),
- Greeting: emailArgs.Greeting,
- LogoImageURL: emailArgs.LogoImageURL,
- LogoClickURL: emailArgs.LogoClickURL,
- LogoAltText: "logo",
- MagicLink: magicLinkResp.MagicLink.String(),
- Meta: meta,
- Subtitle: emailArgs.SubTitle,
- Title: emailArgs.Title,
- ReCATPTCHA: s.Config.PreventRobots.Method == config.PreventRobotsReCAPTCHAV3,
- }
- e := email.Email{
- Subject: emailArgs.Subject,
- TemplateData: tData,
- To: emailArgs.ToEmail,
- }
- err = s.EmailProvider.Send(ctx, e)
- if err != nil {
- return model.EmailLinkCreateResponse{}, fmt.Errorf("failed to send email: %w", err)
- }
-
- linkCreateResponse := model.LinkCreateResults{
- MagicLink: magicLinkResp.MagicLink.String(),
- Secret: magicLinkResp.Secret,
- }
- resp := model.EmailLinkCreateResponse{
- EmailLinkCreateResults: model.EmailLinkCreateResults{
- LinkCreateResults: linkCreateResponse,
- },
- RequestMetadata: model.RequestMetadata{
- UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
- },
- }
-
- return resp, nil
-}
diff --git a/handle/jwt_create.go b/handle/jwt_create.go
index 76b768d..32e15ae 100644
--- a/handle/jwt_create.go
+++ b/handle/jwt_create.go
@@ -27,19 +27,18 @@ var (
ErrJWTAlgNotFound = errors.New("JWT alg not found")
)
-// HandleJWTCreate handles the creation of a JWT.
func (s *Server) HandleJWTCreate(ctx context.Context, req model.ValidJWTCreateRequest) (model.JWTCreateResponse, error) {
- jwtCreateArgs := req.JWTCreateArgs
+ jwtCreateParams := req.JWTCreateParams
- edited, err := s.addRegisteredClaims(ctx, jwtCreateArgs)
+ edited, err := s.addRegisteredClaims(ctx, jwtCreateParams)
if err != nil {
return model.JWTCreateResponse{}, fmt.Errorf("failed to add registered claims to JWT claims: %w", err)
}
options := storage.ReadSigningKeyOptions{
- JWTAlg: jwtCreateArgs.JWTAlg,
+ JWTAlg: jwtCreateParams.Alg,
}
- jwk, err := s.Store.ReadSigningKey(ctx, options)
+ jwk, err := s.Store.SigningKeyRead(ctx, options)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return model.JWTCreateResponse{}, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound)
@@ -70,13 +69,13 @@ func (s *Server) HandleJWTCreate(ctx context.Context, req model.ValidJWTCreateRe
return response, nil
}
-func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCreateArgs) (json.RawMessage, error) {
+func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCreateParams) (json.RawMessage, error) {
sa, ok := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount)
if !ok {
return nil, fmt.Errorf("%w: service account context not found", ctxkey.ErrCtxKey)
}
- valid := json.Valid(args.JWTClaims)
+ valid := json.Valid(args.Claims)
if !valid {
return nil, fmt.Errorf("%w: invalid JSON for JWT claims", model.ErrInvalidModel)
}
@@ -91,7 +90,7 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre
Issuer: s.Config.Iss,
Subject: "", // Don't set.
Audience: jwt.ClaimStrings{sa.Aud.String()},
- ExpiresAt: jwt.NewNumericDate(n.Add(args.JWTLifespan)),
+ ExpiresAt: jwt.NewNumericDate(n.Add(args.Lifespan)),
NotBefore: now,
IssuedAt: now,
ID: u.String(),
@@ -113,8 +112,8 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre
magiclinksdev.AttrJti,
}
- edited := make(json.RawMessage, len(args.JWTClaims))
- copy(edited, args.JWTClaims)
+ edited := make(json.RawMessage, len(args.Claims))
+ copy(edited, args.Claims)
for _, attr := range rfc5119 {
if gjson.GetBytes(edited, attr).Exists() {
return nil, fmt.Errorf("%w: %s", ErrRegisteredClaimProvided, attr)
@@ -136,12 +135,12 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre
return edited, nil
}
-func (s *Server) createLinkArgs(ctx context.Context, args model.ValidLinkCreateArgs) (magiclink.CreateArgs, error) {
- var createArgs magiclink.CreateArgs
+func (s *Server) createLinkParams(ctx context.Context, args model.ValidMagicLinkCreateParams) (magiclink.CreateParams, error) {
+ var createParams magiclink.CreateParams
- edited, err := s.addRegisteredClaims(ctx, args.JWTCreateArgs)
+ edited, err := s.addRegisteredClaims(ctx, args.JWTCreateParams)
if err != nil {
- return createArgs, fmt.Errorf("failed to add registered claims to JWT claims: %w", err)
+ return createParams, fmt.Errorf("failed to add registered claims to JWT claims: %w", err)
}
claims := magiclinksdev.SigningBytesClaims{
@@ -149,24 +148,24 @@ func (s *Server) createLinkArgs(ctx context.Context, args model.ValidLinkCreateA
}
options := storage.ReadSigningKeyOptions{
- JWTAlg: args.JWTCreateArgs.JWTAlg,
+ JWTAlg: args.JWTCreateParams.Alg,
}
- jwk, err := s.Store.ReadSigningKey(ctx, options)
+ jwk, err := s.Store.SigningKeyRead(ctx, options)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
- return createArgs, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound)
+ return createParams, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound)
}
- return createArgs, fmt.Errorf("failed to get JWT signing key: %w", err)
+ return createParams, fmt.Errorf("failed to get JWT signing key: %w", err)
}
kID := jwk.Marshal().KID
- createArgs = magiclink.CreateArgs{
- Expires: time.Now().Add(args.LinkLifespan),
+ createParams = magiclink.CreateParams{
+ Expires: time.Now().Add(args.Lifespan),
JWTClaims: claims,
JWTKeyID: &kID,
RedirectQueryKey: args.RedirectQueryKey,
RedirectURL: args.RedirectURL,
}
- return createArgs, nil
+ return createParams, nil
}
diff --git a/handle/jwt_valdiate.go b/handle/jwt_valdiate.go
index 62bc80c..6366615 100644
--- a/handle/jwt_valdiate.go
+++ b/handle/jwt_valdiate.go
@@ -21,12 +21,11 @@ var (
ErrToken = errors.New("JWT invalid")
)
-// HandleJWTValidate handles the JWT validation endpoint.
func (s *Server) HandleJWTValidate(ctx context.Context, req model.ValidJWTValidateRequest) (model.JWTValidateResponse, error) {
- jwtValidateArgs := req.JWTValidateArgs
+ jwtValidateParams := req.JWTValidateParams
sa := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount)
- token, err := jwt.Parse(jwtValidateArgs.JWT, func(token *jwt.Token) (any, error) {
+ token, err := jwt.Parse(jwtValidateParams.JWT, func(token *jwt.Token) (any, error) {
jwksBytes, err := s.JWKS.JSONPublic(ctx) // Change to JSONPrivate if HMAC support is added.
if err != nil {
return nil, fmt.Errorf("failed to get JWKS JSON: %w", err)
diff --git a/handle/link.go b/handle/link.go
deleted file mode 100644
index 0dfed4e..0000000
--- a/handle/link.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package handle
-
-import (
- "context"
- "fmt"
-
- "github.com/google/uuid"
-
- "github.com/MicahParks/magiclinksdev/magiclink"
- "github.com/MicahParks/magiclinksdev/model"
-
- "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
-)
-
-// HandleLinkCreate handles the link creation endpoint.
-func (s *Server) HandleLinkCreate(ctx context.Context, req model.ValidLinkCreateRequest) (response model.LinkCreateResponse, err error) {
- linkArgs := req.LinkArgs
-
- magicLinkResp, err := s.createLink(ctx, linkArgs)
- if err != nil {
- return model.LinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
- }
-
- resp := model.LinkCreateResponse{
- LinkCreateResults: model.LinkCreateResults{
- MagicLink: magicLinkResp.MagicLink.String(),
- Secret: magicLinkResp.Secret,
- },
- RequestMetadata: model.RequestMetadata{
- UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
- },
- }
-
- return resp, nil
-}
-
-func (s *Server) createLink(ctx context.Context, linkArgs model.ValidLinkCreateArgs) (magiclink.CreateResponse, error) {
- magicLinkCreateArgs, err := s.createLinkArgs(ctx, linkArgs)
- if err != nil {
- return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link create args: %w", err)
- }
-
- magicLinkResp, err := s.MagicLink.NewLink(ctx, magicLinkCreateArgs)
- if err != nil {
- return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
- }
-
- return magicLinkResp, nil
-}
diff --git a/handle/magic_link_create.go b/handle/magic_link_create.go
new file mode 100644
index 0000000..fde26de
--- /dev/null
+++ b/handle/magic_link_create.go
@@ -0,0 +1,48 @@
+package handle
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+
+ "github.com/MicahParks/magiclinksdev/magiclink"
+ "github.com/MicahParks/magiclinksdev/model"
+
+ "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+)
+
+func (s *Server) HandleMagicLinkCreate(ctx context.Context, req model.ValidMagicLinkCreateRequest) (response model.MagicLinkCreateResponse, err error) {
+ linkParams := req.MagicLinkParams
+
+ magicLinkRes, err := s.createLink(ctx, linkParams)
+ if err != nil {
+ return model.MagicLinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
+ }
+
+ resp := model.MagicLinkCreateResponse{
+ MagicLinkCreateResults: model.MagicLinkCreateResults{
+ MagicLink: magicLinkRes.MagicLink.String(),
+ Secret: magicLinkRes.Secret,
+ },
+ RequestMetadata: model.RequestMetadata{
+ UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
+ },
+ }
+
+ return resp, nil
+}
+
+func (s *Server) createLink(ctx context.Context, linkParams model.ValidMagicLinkCreateParams) (magiclink.CreateResponse, error) {
+ magicLinkCreateParams, err := s.createLinkParams(ctx, linkParams)
+ if err != nil {
+ return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link create args: %w", err)
+ }
+
+ magicLinkRes, err := s.MagicLink.NewLink(ctx, magicLinkCreateParams)
+ if err != nil {
+ return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
+ }
+
+ return magicLinkRes, nil
+}
diff --git a/handle/magic_link_email_create.go b/handle/magic_link_email_create.go
new file mode 100644
index 0000000..56eee48
--- /dev/null
+++ b/handle/magic_link_email_create.go
@@ -0,0 +1,68 @@
+package handle
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+
+ "github.com/MicahParks/magiclinksdev/config"
+ "github.com/MicahParks/magiclinksdev/email"
+ "github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+)
+
+func (s *Server) HandleMagicLinkEmailCreate(ctx context.Context, req model.ValidMagicLinkEmailCreateRequest) (model.MagicLinkEmailCreateResponse, error) {
+ emailParams := req.MagicLinkEmailCreateParams
+ linkParams := req.MagicLinkCreateParams
+
+ magicLinkRes, err := s.createLink(ctx, linkParams)
+ if err != nil {
+ return model.MagicLinkEmailCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err)
+ }
+
+ meta := email.TemplateMetadata{
+ HTMLInstruction: fmt.Sprintf("Magic link from %s.", emailParams.ServiceName),
+ HTMLTitle: fmt.Sprintf("Magic link from %s", emailParams.ServiceName),
+ MSOButtonStop: email.MSOButtonStop,
+ MSOButtonStart: email.MSOButtonStart,
+ MSOHead: email.MSOHead,
+ }
+ tData := email.MagicLinkTemplateData{
+ ButtonText: emailParams.ButtonText,
+ Expiration: linkParams.Lifespan.String(),
+ Greeting: emailParams.Greeting,
+ LogoImageURL: emailParams.LogoImageURL,
+ LogoClickURL: emailParams.LogoClickURL,
+ LogoAltText: "logo",
+ MagicLink: magicLinkRes.MagicLink.String(),
+ Meta: meta,
+ Subtitle: emailParams.SubTitle,
+ Title: emailParams.Title,
+ ReCATPTCHA: s.Config.PreventRobots.Method == config.PreventRobotsReCAPTCHAV3,
+ }
+ e := email.Email{
+ Subject: emailParams.Subject,
+ TemplateData: tData,
+ To: emailParams.ToEmail,
+ }
+ err = s.EmailProvider.SendMagicLink(ctx, e)
+ if err != nil {
+ return model.MagicLinkEmailCreateResponse{}, fmt.Errorf("failed to send email: %w", err)
+ }
+
+ linkCreateResponse := model.MagicLinkCreateResults{
+ MagicLink: magicLinkRes.MagicLink.String(),
+ Secret: magicLinkRes.Secret,
+ }
+ resp := model.MagicLinkEmailCreateResponse{
+ MagicLinkEmailCreateResults: model.MagicLinkEmailCreateResults{
+ MagicLinkCreateResults: linkCreateResponse,
+ },
+ RequestMetadata: model.RequestMetadata{
+ UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
+ },
+ }
+
+ return resp, nil
+}
diff --git a/handle/otp_create.go b/handle/otp_create.go
new file mode 100644
index 0000000..af8a19c
--- /dev/null
+++ b/handle/otp_create.go
@@ -0,0 +1,45 @@
+package handle
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+ "github.com/MicahParks/magiclinksdev/otp"
+)
+
+func (s *Server) HandleOTPCreate(ctx context.Context, req model.ValidOTPCreateRequest) (response model.OTPCreateResponse, err error) {
+ otpParams := createOTPParams(req.OTPCreateParams)
+
+ otpRes, err := s.Store.OTPCreate(ctx, otpParams)
+ if err != nil {
+ return model.OTPCreateResponse{}, fmt.Errorf("failed to create OTP: %w", err)
+ }
+
+ resp := model.OTPCreateResponse{
+ OTPCreateResults: model.OTPCreateResults{
+ ID: otpRes.ID,
+ OTP: otpRes.OTP,
+ },
+ RequestMetadata: model.RequestMetadata{
+ UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
+ },
+ }
+
+ return resp, nil
+}
+
+func createOTPParams(otpParams model.ValidOTPCreateParams) otp.CreateParams {
+ params := otp.CreateParams{
+ CharSetAlphaLower: otpParams.CharSetAlphaLower,
+ CharSetAlphaUpper: otpParams.CharSetAlphaUpper,
+ CharSetNumeric: otpParams.CharSetNumeric,
+ Expires: time.Now().Add(otpParams.Lifespan),
+ Length: otpParams.Length,
+ }
+ return params
+}
diff --git a/handle/otp_email_create.go b/handle/otp_email_create.go
new file mode 100644
index 0000000..e696e6a
--- /dev/null
+++ b/handle/otp_email_create.go
@@ -0,0 +1,64 @@
+package handle
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+
+ "github.com/MicahParks/magiclinksdev/email"
+ "github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+)
+
+func (s *Server) HandleOTPEmailCreate(ctx context.Context, req model.ValidOTPEmailCreateRequest) (response model.OTPEmailCreateResponse, err error) {
+ emailParams := req.OTPEmailCreateParams
+ otpParams := createOTPParams(req.OTPCreateParams)
+
+ otpRes, err := s.Store.OTPCreate(ctx, otpParams)
+ if err != nil {
+ return model.OTPEmailCreateResponse{}, fmt.Errorf("failed to create OTP: %w", err)
+ }
+
+ meta := email.TemplateMetadata{
+ HTMLInstruction: fmt.Sprintf("One-time password from %s.", emailParams.ServiceName),
+ HTMLTitle: fmt.Sprintf("One-time password from %s", emailParams.ServiceName),
+ MSOButtonStop: email.MSOButtonStop,
+ MSOButtonStart: email.MSOButtonStart,
+ MSOHead: email.MSOHead,
+ }
+ tData := email.OTPTemplateData{
+ Expiration: req.OTPCreateParams.Lifespan.String(),
+ Greeting: emailParams.Greeting,
+ Meta: meta,
+ OTP: otpRes.OTP,
+ Subtitle: emailParams.SubTitle,
+ Title: emailParams.Title,
+ LogoImageURL: emailParams.LogoImageURL,
+ LogoClickURL: emailParams.LogoClickURL,
+ LogoAltText: "logo",
+ }
+ e := email.Email{
+ Subject: emailParams.Subject,
+ TemplateData: tData,
+ To: emailParams.ToEmail,
+ }
+ err = s.EmailProvider.SendOTP(ctx, e)
+ if err != nil {
+ return model.OTPEmailCreateResponse{}, fmt.Errorf("failed to send email: %w", err)
+ }
+
+ resp := model.OTPEmailCreateResponse{
+ OTPEmailCreateResults: model.OTPEmailCreateResults{
+ OTPCreateResults: model.OTPCreateResults{
+ ID: otpRes.ID,
+ OTP: otpRes.OTP,
+ },
+ },
+ RequestMetadata: model.RequestMetadata{
+ UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
+ },
+ }
+
+ return resp, nil
+}
diff --git a/handle/otp_validate.go b/handle/otp_validate.go
new file mode 100644
index 0000000..0dde229
--- /dev/null
+++ b/handle/otp_validate.go
@@ -0,0 +1,25 @@
+package handle
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+
+ "github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+)
+
+func (s *Server) HandleOTPValidate(ctx context.Context, req model.ValidOTPValidateRequest) (response model.OTPValidateResponse, err error) {
+ err = s.Store.OTPValidate(ctx, req.OTPValidateParams.ID, req.OTPValidateParams.OTP)
+ if err != nil {
+ return model.OTPValidateResponse{}, fmt.Errorf("failed to validate OTP: %w", err)
+ }
+ resp := model.OTPValidateResponse{
+ OTPValidateResults: model.OTPValidateResults{},
+ RequestMetadata: model.RequestMetadata{
+ UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID),
+ },
+ }
+ return resp, nil
+}
diff --git a/handle/server.go b/handle/server.go
index 7fe905f..21b30d6 100644
--- a/handle/server.go
+++ b/handle/server.go
@@ -51,7 +51,6 @@ type MiddlewareHook interface {
// MiddlewareHookFunc is a function that can be used to modify the middleware options.
type MiddlewareHookFunc func(options MiddlewareOptions) MiddlewareOptions
-// Hook implements the MiddlewareHook interface.
func (h MiddlewareHookFunc) Hook(options MiddlewareOptions) MiddlewareOptions {
return h(options)
}
diff --git a/handle/service_account.go b/handle/service_account_create.go
similarity index 80%
rename from handle/service_account.go
rename to handle/service_account_create.go
index 3abcf3c..d9d3a5e 100644
--- a/handle/service_account.go
+++ b/handle/service_account_create.go
@@ -12,20 +12,20 @@ import (
// HandleServiceAccountCreate handles the service account creation endpoint.
func (s *Server) HandleServiceAccountCreate(ctx context.Context, args model.ValidServiceAccountCreateRequest) (model.ServiceAccountCreateResponse, error) {
- saArgs := args.CreateServiceAccountArgs
+ saParams := args.ServiceAccountCreateParams
- createdSA, err := s.Store.CreateSA(ctx, saArgs)
+ createdSA, err := s.Store.SACreate(ctx, saParams)
if err != nil {
return model.ServiceAccountCreateResponse{}, fmt.Errorf("failed to create service account: %w", err)
}
- serviceAccount, err := s.Store.ReadSA(ctx, createdSA.UUID)
+ serviceAccount, err := s.Store.SARead(ctx, createdSA.UUID)
if err != nil {
return model.ServiceAccountCreateResponse{}, fmt.Errorf("failed to get service account as marshallable data structure: %w", err)
}
resp := model.ServiceAccountCreateResponse{
- CreateServiceAccountResults: model.ServiceAccountCreateResults{
+ ServiceAccountCreateResults: model.ServiceAccountCreateResults{
ServiceAccount: serviceAccount,
},
RequestMetadata: model.RequestMetadata{
diff --git a/magic_link_test.go b/magic_link_test.go
index b2781e6..291eb1e 100644
--- a/magic_link_test.go
+++ b/magic_link_test.go
@@ -29,16 +29,14 @@ type testClaims struct {
jwt.RegisteredClaims
}
-type testCase struct {
- name string
- keyfunc jwt.Keyfunc
- reqBody model.LinkCreateRequest
-}
-
func TestMagicLink(t *testing.T) {
const customRedirectQueryKey = "customRedirectQueryKey"
- for _, tc := range []testCase{
+ for _, tc := range []struct {
+ name string
+ keyfunc jwt.Keyfunc
+ reqBody model.MagicLinkCreateRequest
+ }{
{
name: "Default signing key",
keyfunc: func(token *jwt.Token) (any, error) {
@@ -48,9 +46,10 @@ func TestMagicLink(t *testing.T) {
if err != nil {
panic(fmt.Sprintf("failed to begin transaction: %v", err))
}
+ //goland:noinspection GoUnhandledErrorResult
defer tx.Rollback(ctx)
ctx = context.WithValue(ctx, ctxkey.Tx, tx)
- defaultKey, err := server.Store.ReadDefaultSigningKey(ctx)
+ defaultKey, err := server.Store.SigningKeyDefaultRead(ctx)
if err != nil {
panic(fmt.Sprintf("failed to read default signing key: %v", err))
}
@@ -64,13 +63,13 @@ func TestMagicLink(t *testing.T) {
}
return ed.Public(), nil
},
- reqBody: model.LinkCreateRequest{
- LinkArgs: model.LinkCreateArgs{
- JWTCreateArgs: model.JWTCreateArgs{
- JWTClaims: map[string]string{"foo": "bar"},
- JWTLifespanSeconds: 0,
+ reqBody: model.MagicLinkCreateRequest{
+ MagicLinkCreateParams: model.MagicLinkCreateParams{
+ JWTCreateParams: model.JWTCreateParams{
+ Claims: map[string]string{"foo": "bar"},
+ LifespanSeconds: 0,
},
- LinkLifespan: 0,
+ LifespanSeconds: 0,
RedirectQueryKey: customRedirectQueryKey,
RedirectURL: "https://github.com/MicahParks/magiclinksdev",
},
@@ -90,14 +89,14 @@ func TestMagicLink(t *testing.T) {
}
panic("no RSA signing key")
},
- reqBody: model.LinkCreateRequest{
- LinkArgs: model.LinkCreateArgs{
- JWTCreateArgs: model.JWTCreateArgs{
- JWTAlg: jwkset.AlgRS256.String(),
- JWTClaims: map[string]string{"foo": "bar"},
- JWTLifespanSeconds: 0,
+ reqBody: model.MagicLinkCreateRequest{
+ MagicLinkCreateParams: model.MagicLinkCreateParams{
+ JWTCreateParams: model.JWTCreateParams{
+ Alg: jwkset.AlgRS256.String(),
+ Claims: map[string]string{"foo": "bar"},
+ LifespanSeconds: 0,
},
- LinkLifespan: 0,
+ LifespanSeconds: 0,
RedirectQueryKey: customRedirectQueryKey,
RedirectURL: "https://github.com/MicahParks/magiclinksdev",
},
@@ -111,7 +110,7 @@ func TestMagicLink(t *testing.T) {
}
recorder := httptest.NewRecorder()
- u, err := assets.conf.Server.BaseURL.Get().Parse(network.PathLinkCreate)
+ u, err := assets.conf.Server.BaseURL.Get().Parse(network.PathMagicLinkCreate)
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}
@@ -127,14 +126,14 @@ func TestMagicLink(t *testing.T) {
t.Fatalf("Received non-JSON content type: %s", recorder.Header().Get(mld.HeaderContentType))
}
- var linkCreateResponse model.LinkCreateResponse
+ var linkCreateResponse model.MagicLinkCreateResponse
err = json.Unmarshal(recorder.Body.Bytes(), &linkCreateResponse)
if err != nil {
t.Fatalf("Failed to unmarshal response body: %v", err)
}
recorder = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodGet, linkCreateResponse.LinkCreateResults.MagicLink, nil)
+ req = httptest.NewRequest(http.MethodGet, linkCreateResponse.MagicLinkCreateResults.MagicLink, nil)
reqSent := time.Now()
assets.mux.ServeHTTP(recorder, req)
@@ -180,12 +179,12 @@ func TestMagicLink(t *testing.T) {
}
redirectURL.RawQuery = ""
- if redirectURL.String() != tc.reqBody.LinkArgs.RedirectURL {
- t.Fatalf("Expected redirect URL %q, got %q", tc.reqBody.LinkArgs.RedirectURL, redirectURL.String())
+ if redirectURL.String() != tc.reqBody.MagicLinkCreateParams.RedirectURL {
+ t.Fatalf("Expected redirect URL %q, got %q", tc.reqBody.MagicLinkCreateParams.RedirectURL, redirectURL.String())
}
recorder = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodGet, linkCreateResponse.LinkCreateResults.MagicLink, nil)
+ req = httptest.NewRequest(http.MethodGet, linkCreateResponse.MagicLinkCreateResults.MagicLink, nil)
assets.mux.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
diff --git a/magiclink/frontend/default.css b/magiclink/frontend/default.css
index b93d96a..2746a1e 100644
--- a/magiclink/frontend/default.css
+++ b/magiclink/frontend/default.css
@@ -1 +1 @@
-/*! tailwindcss v3.3.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.mx-auto{margin-left:auto;margin-right:auto}.mt-10{margin-top:2.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.mt-2{margin-top:.5rem}.flex{display:flex}.h-full{height:100%}.min-h-full{min-height:100%}.max-w-7xl{max-width:80rem}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.rounded-md{border-radius:.375rem}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.pt-24{padding-top:6rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.leading-5{line-height:1.25rem}.leading-7{line-height:1.75rem}.tracking-tight{letter-spacing:-.025em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-600:focus-visible{outline-color:#4f46e5}@media (min-width:640px){.sm\:pt-32{padding-top:8rem}.sm\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}
\ No newline at end of file
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.mx-auto{margin-left:auto;margin-right:auto}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.hidden{display:none}.h-full{height:100%}.min-h-full{min-height:100%}.max-w-7xl{max-width:80rem}.max-w-5xl{max-width:64rem}.max-w-2xl{max-width:42rem}.max-w-xl{max-width:36rem}.max-w-fit{max-width:-moz-fit-content;max-width:fit-content}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.rounded-md{border-radius:.375rem}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.px-4{padding-left:1rem;padding-right:1rem}.pt-24{padding-top:6rem}.text-left{text-align:left}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.leading-5{line-height:1.25rem}.leading-7{line-height:1.75rem}.tracking-tight{letter-spacing:-.025em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-600:focus-visible{outline-color:#4f46e5}@media (min-width:640px){.sm\:pt-32{padding-top:8rem}.sm\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}}
\ No newline at end of file
diff --git a/magiclink/frontend/recaptchav3.gohtml b/magiclink/frontend/recaptchav3.gohtml
index 34ec698..98ffa58 100644
--- a/magiclink/frontend/recaptchav3.gohtml
+++ b/magiclink/frontend/recaptchav3.gohtml
@@ -9,26 +9,41 @@
-
-
+
+
{{.Code}}
-
+
{{.Title}}
-
+
{{.Instruction}}
-
- If the page does not redirect, the magic link may have expired.
-
+
+
+ Please request another magic link.
+
+ This magic link is invalid for one of the below reasons:
+
+
+ The magic link has expired.
+ The magic link never existed.
+ The magic link has already been used.
+ Your web browser failed an automated check.
+
+
{{- if .ButtonBypass -}}
-
+
{{- end -}}
@@ -44,8 +59,18 @@
grecaptcha.execute('{{.SiteKey}}', {action: 'submit'}).then(function (token) {
let xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function () {
- if (xmlHttp.readyState === 4 && xmlHttp.status === 200) {
- window.location.replace(xmlHttp.responseText);
+ if (xmlHttp.readyState === 4) {
+ if (xmlHttp.status === 200) {
+ window.location.replace(xmlHttp.responseText);
+ } else {
+ document.getElementById('subtitle').innerText = 'INVALID';
+ document.getElementById('title').innerText = 'Invalid magic link';
+ document.getElementById('instruction').classList.add('hidden');
+ document.getElementById('invalid').classList.remove('hidden');
+ {{- if .ButtonBypass -}}
+ document.getElementById('button-bypass').classList.add('hidden');
+ {{- end -}}
+ }
}
};
let u = new URL(window.location.href);
diff --git a/magiclink/jwks.go b/magiclink/jwks.go
index c716f51..48b3175 100644
--- a/magiclink/jwks.go
+++ b/magiclink/jwks.go
@@ -18,13 +18,13 @@ const DefaultJWKSCacheRefresh = 5 * time.Minute
type jwksCache struct {
cached json.RawMessage
- jwks jwkset.Storage
+ storage jwkset.Storage
lastRefresh time.Time
refresh time.Duration
mux sync.RWMutex
}
-func newJWKSCache(ctx context.Context, config JWKSArgs) (*jwksCache, error) {
+func newJWKSCache(ctx context.Context, config JWKSParams) (*jwksCache, error) {
store := config.Store
if store == nil {
store = jwkset.NewMemoryStorage()
@@ -67,7 +67,7 @@ func newJWKSCache(ctx context.Context, config JWKSArgs) (*jwksCache, error) {
jCache := &jwksCache{
cached: initialCache,
- jwks: store,
+ storage: store,
lastRefresh: time.Now(),
refresh: cacheRefresh,
}
@@ -88,7 +88,7 @@ func (j *jwksCache) get(ctx context.Context) (json.RawMessage, error) {
j.mux.Lock()
defer j.mux.Unlock()
- body, err := j.jwks.JSONPublic(ctx)
+ body, err := j.storage.JSONPublic(ctx)
if err != nil {
return nil, fmt.Errorf("failed to refresh the JWK Set: %w", err)
}
diff --git a/magiclink/magic_link.go b/magiclink/magic_link.go
index 74852e7..a8aacf6 100644
--- a/magiclink/magic_link.go
+++ b/magiclink/magic_link.go
@@ -90,17 +90,17 @@ func (m MagicLink) JWKSHandler() http.Handler {
// JWKSet is a getter method to return the underlying JWK Set.
func (m MagicLink) JWKSet() jwkset.Storage {
- return m.jwks.jwks
+ return m.jwks.storage
}
// NewLink creates a magic link with the given parameters.
-func (m MagicLink) NewLink(ctx context.Context, args CreateArgs) (CreateResponse, error) {
+func (m MagicLink) NewLink(ctx context.Context, args CreateParams) (CreateResponse, error) {
err := args.Valid()
if err != nil {
return CreateResponse{}, fmt.Errorf("failed to validate args: %w", err)
}
- secret, err := m.Store.CreateLink(ctx, args)
+ secret, err := m.Store.MagicLinkCreate(ctx, args)
if err != nil {
return CreateResponse{}, fmt.Errorf("failed to create link: %w", err)
}
@@ -131,7 +131,7 @@ func (m MagicLink) MagicLinkHandler() http.Handler {
}
if m.customRedirector != nil {
- args := RedirectorArgs{
+ args := RedirectorParams{
ReadAndExpireLink: m.HandleMagicLink,
Request: r,
Secret: secret,
@@ -156,8 +156,8 @@ func (m MagicLink) MagicLinkHandler() http.Handler {
}
// HandleMagicLink is a method that accepts a magic link secret, then returns the signed JWT.
-func (m MagicLink) HandleMagicLink(ctx context.Context, secret string) (jwtB64 string, response ReadResponse, err error) {
- response, err = m.Store.ReadLink(ctx, secret)
+func (m MagicLink) HandleMagicLink(ctx context.Context, secret string) (jwtB64 string, response ReadResult, err error) {
+ response, err = m.Store.MagicLinkRead(ctx, secret)
if err != nil {
if errors.Is(err, ErrLinkNotFound) {
return "", response, err
@@ -166,28 +166,28 @@ func (m MagicLink) HandleMagicLink(ctx context.Context, secret string) (jwtB64 s
}
var jwk jwkset.JWK
- if response.CreateArgs.JWTKeyID != nil {
- jwk, err = m.jwks.jwks.KeyRead(ctx, *response.CreateArgs.JWTKeyID)
+ if response.CreateParams.JWTKeyID != nil {
+ jwk, err = m.jwks.storage.KeyRead(ctx, *response.CreateParams.JWTKeyID)
if err != nil {
return "", response, fmt.Errorf("%w: %s", ErrJWKSReadGivenKID, err)
}
} else {
- allKeys, err := m.jwks.jwks.KeyReadAll(ctx)
+ allKeys, err := m.jwks.storage.KeyReadAll(ctx)
if err != nil {
return "", response, fmt.Errorf("%w: %s", ErrJWKSSnapshot, err)
}
if len(allKeys) == 0 {
return "", response, ErrJWKSEmpty
}
- jwk = allKeys[0]
+ jwk = allKeys[0] // First key is default signing key in PostgreSQL implementation.
}
- signingMethod := jwt.GetSigningMethod(response.CreateArgs.JWTSigningMethod)
+ signingMethod := jwt.GetSigningMethod(response.CreateParams.JWTSigningMethod)
if signingMethod == nil {
signingMethod = BestSigningMethod(jwk.Key())
}
- token := jwt.NewWithClaims(signingMethod, response.CreateArgs.JWTClaims)
+ token := jwt.NewWithClaims(signingMethod, response.CreateParams.JWTClaims)
token.Header[jwkset.HeaderKID] = jwk.Marshal().KID
jwtB64, err = token.SignedString(jwk.Key())
if err != nil {
@@ -198,7 +198,7 @@ func (m MagicLink) HandleMagicLink(ctx context.Context, secret string) (jwtB64 s
}
func (m MagicLink) handleError(err error, suggestedResponseCode int, request *http.Request, writer http.ResponseWriter) {
- args := ErrorHandlerArgs{
+ args := ErrorHandlerParams{
Err: err,
Request: request,
SuggestedResponseCode: suggestedResponseCode,
@@ -235,10 +235,10 @@ func BestSigningMethod(key any) jwt.SigningMethod {
return signingMethod
}
-func redirectURLFromResponse(response ReadResponse, jwtB64 string) *url.URL {
- u := copyURL(response.CreateArgs.RedirectURL)
+func redirectURLFromResponse(response ReadResult, jwtB64 string) *url.URL {
+ u := copyURL(response.CreateParams.RedirectURL)
query := u.Query()
- queryKey := response.CreateArgs.RedirectQueryKey
+ queryKey := response.CreateParams.RedirectQueryKey
if queryKey == "" {
queryKey = DefaultRedirectQueryKey
}
diff --git a/magiclink/magic_link_cases_test.go b/magiclink/magic_link_cases_test.go
index ec6f652..8d2ac2e 100644
--- a/magiclink/magic_link_cases_test.go
+++ b/magiclink/magic_link_cases_test.go
@@ -25,7 +25,7 @@ func makeCases(t *testing.T) []testCase {
defer cancel()
noGivenJWKSStoreCase := testCase{
- createArgs: []createArg{
+ createParams: []createParams{
{},
},
name: "No Given JWKS store",
@@ -51,7 +51,7 @@ func makeCases(t *testing.T) []testCase {
}
fourTypesOfKeys := testCase{
- createArgs: []createArg{
+ createParams: []createParams{
{
JWTKeyID: &ecdsaKID,
},
@@ -65,14 +65,14 @@ func makeCases(t *testing.T) []testCase {
JWTKeyID: &hmacKID,
},
},
- setupParam: setupArgs{
+ setupParam: setupParams{
jwksStore: jwksStoreWithAllKeys,
},
name: "Four types of keys present",
}
getJWKS := testCase{
- setupParam: setupArgs{
+ setupParam: setupParams{
jwksGet: true,
jwksStore: jwksStoreWithAllKeys,
},
@@ -80,7 +80,7 @@ func makeCases(t *testing.T) []testCase {
}
getJWKSCacheRefresh := testCase{
- setupParam: setupArgs{
+ setupParam: setupParams{
jwksGet: true,
jwksGetDelay: 51 * time.Millisecond,
jwksCacheRefresh: 50 * time.Millisecond,
diff --git a/magiclink/magic_link_test.go b/magiclink/magic_link_test.go
index b9e0cdd..567b00c 100644
--- a/magiclink/magic_link_test.go
+++ b/magiclink/magic_link_test.go
@@ -37,7 +37,7 @@ type jwtClaims struct {
jwt.RegisteredClaims
}
-type setupArgs struct {
+type setupParams struct {
errorHandler magiclink.ErrorHandler
jwksGet bool
jwksGetDelay time.Duration
@@ -46,16 +46,16 @@ type setupArgs struct {
secretQueryKey string
}
-type createArg struct {
+type createParams struct {
JWTKeyID *string
JWTSigningMethod string
RedirectQueryKey string
}
type testCase struct {
- createArgs []createArg
- setupParam setupArgs
- name string
+ createParams []createParams
+ setupParam setupParams
+ name string
}
func TestTable(t *testing.T) {
@@ -71,12 +71,12 @@ func TestTable(t *testing.T) {
for _, tc := range makeCases(t) {
t.Run(tc.name, func(t *testing.T) {
- testCreateCases(ctx, t, appServer, tc.createArgs, redirectChan, tc.setupParam)
+ testCreateCases(ctx, t, appServer, tc.createParams, redirectChan, tc.setupParam)
})
}
}
-func testCreateCases(ctx context.Context, t *testing.T, appServer *httptest.Server, createArgs []createArg, redirectChan <-chan url.Values, sParam setupArgs) {
+func testCreateCases(ctx context.Context, t *testing.T, appServer *httptest.Server, createParams []createParams, redirectChan <-chan url.Values, sParam setupParams) {
m, magicServer := magiclinkSetup(ctx, t, sParam)
defer magicServer.Close()
@@ -108,11 +108,11 @@ func testCreateCases(ctx context.Context, t *testing.T, appServer *httptest.Serv
_ = resp.Body.Close()
}
- for _, cParam := range createArgs {
+ for _, cParam := range createParams {
if cParam.RedirectQueryKey == "" {
cParam.RedirectQueryKey = magiclink.DefaultRedirectQueryKey
}
- cP := magiclink.CreateArgs{
+ cP := magiclink.CreateParams{
Expires: time.Now().Add(mldtest.LinksExpireAfter),
JWTClaims: claims,
JWTKeyID: cParam.JWTKeyID,
@@ -120,12 +120,12 @@ func testCreateCases(ctx context.Context, t *testing.T, appServer *httptest.Serv
RedirectQueryKey: cParam.RedirectQueryKey,
RedirectURL: redirectURL,
}
- createResp, err := m.NewLink(ctx, cP)
+ createRes, err := m.NewLink(ctx, cP)
if err != nil {
t.Fatalf("Failed to create magic link: %s", err)
}
- resp, err := http.Get(createResp.MagicLink.String())
+ resp, err := http.Get(createRes.MagicLink.String())
if err != nil {
t.Fatalf("Failed to GET magic link: %s", err)
}
@@ -153,7 +153,7 @@ func testCreateCases(ctx context.Context, t *testing.T, appServer *httptest.Serv
}
}
-func magiclinkSetup(ctx context.Context, t *testing.T, args setupArgs) (magiclink.MagicLink, *httptest.Server) {
+func magiclinkSetup(ctx context.Context, t *testing.T, args setupParams) (magiclink.MagicLink, *httptest.Server) {
dH := &dynamicHandler{}
server := httptest.NewServer(dH)
serviceURL, err := url.Parse(server.URL)
@@ -170,7 +170,7 @@ func magiclinkSetup(ctx context.Context, t *testing.T, args setupArgs) (magiclin
ServiceURL: serviceURL,
SecretQueryKey: args.secretQueryKey,
Store: nil,
- JWKS: magiclink.JWKSArgs{
+ JWKS: magiclink.JWKSParams{
CacheRefresh: args.jwksCacheRefresh,
Store: args.jwksStore,
},
diff --git a/magiclink/recaptchav3.go b/magiclink/recaptchav3.go
index e092070..d0be3f4 100644
--- a/magiclink/recaptchav3.go
+++ b/magiclink/recaptchav3.go
@@ -28,7 +28,6 @@ type ReCAPTCHAV3Config struct {
Verifier recaptcha.VerifierV3 `json:"-"`
}
-// DefaultsAndValidate implements the jsontype.Config interface.
func (r ReCAPTCHAV3Config) DefaultsAndValidate() (ReCAPTCHAV3Config, error) {
if r.MinScore == 0 {
r.MinScore = 0.5
@@ -74,8 +73,7 @@ func NewReCAPTCHAV3Redirector(config ReCAPTCHAV3Config) Redirector {
return r
}
-// Redirect implements the Redirector interface.
-func (r ReCAPTCHAV3Redirector) Redirect(args RedirectorArgs) {
+func (r ReCAPTCHAV3Redirector) Redirect(args RedirectorParams) {
ctx := args.Request.Context()
token := args.Request.URL.Query().Get("token")
@@ -147,10 +145,7 @@ func (r ReCAPTCHAV3TemplateData) DefaultsAndValidate() (ReCAPTCHAV3TemplateData,
r.CSS = template.CSS(defaultCSS)
}
if r.Instruction == "" {
- if r.ButtonBypass {
- r.Instruction = "Please click the button below to continue. "
- }
- r.Instruction += "This page helps prevent robots from using magic links."
+ r.Instruction += "This page helps prevent robots from using magic links. You should be redirected automatically."
}
if r.HTMLTitle == "" {
r.HTMLTitle = "Magic Link - Browser Check"
diff --git a/magiclink/recaptchav3_test.go b/magiclink/recaptchav3_test.go
index 052f7e0..f0dd57c 100644
--- a/magiclink/recaptchav3_test.go
+++ b/magiclink/recaptchav3_test.go
@@ -101,10 +101,10 @@ func TestReCAPTCHAV3Redirector_Redirect(t *testing.T) {
t.Fatalf("Failed to create request: %v.", err)
}
recorder := httptest.NewRecorder()
- args := magiclink.RedirectorArgs{
- ReadAndExpireLink: func(ctx context.Context, secret string) (jwtB64 string, response magiclink.ReadResponse, err error) {
- return jwtB64FromBackend, magiclink.ReadResponse{
- CreateArgs: magiclink.CreateArgs{
+ args := magiclink.RedirectorParams{
+ ReadAndExpireLink: func(ctx context.Context, secret string) (jwtB64 string, response magiclink.ReadResult, err error) {
+ return jwtB64FromBackend, magiclink.ReadResult{
+ CreateParams: magiclink.CreateParams{
Expires: time.Now().Add(mldtest.LinksExpireAfter),
RedirectQueryKey: magiclink.DefaultRedirectQueryKey,
RedirectURL: must(url.Parse(magicLinkTarget)),
diff --git a/magiclink/redirector.go b/magiclink/redirector.go
index 0f0ccc7..20444b2 100644
--- a/magiclink/redirector.go
+++ b/magiclink/redirector.go
@@ -5,9 +5,9 @@ import (
"net/http"
)
-// RedirectorArgs are passed to a Redirector when performing a redirect.
-type RedirectorArgs struct {
- ReadAndExpireLink func(ctx context.Context, secret string) (jwtB64 string, response ReadResponse, err error)
+// RedirectorParams are passed to a Redirector when performing a redirect.
+type RedirectorParams struct {
+ ReadAndExpireLink func(ctx context.Context, secret string) (jwtB64 string, response ReadResult, err error)
Request *http.Request
Secret string
Writer http.ResponseWriter
@@ -15,5 +15,5 @@ type RedirectorArgs struct {
// Redirector is a custom implementation of redirecting a user to a magic link target.
type Redirector interface {
- Redirect(args RedirectorArgs)
+ Redirect(args RedirectorParams)
}
diff --git a/magiclink/storage.go b/magiclink/storage.go
index fbacf83..0afec9f 100644
--- a/magiclink/storage.go
+++ b/magiclink/storage.go
@@ -13,27 +13,28 @@ import (
// Storage represents the underlying storage for the MagicLink service.
type Storage interface {
- // CreateLink creates a secret for the given parameters and stores the pair. The secret is returned to the caller.
- CreateLink(ctx context.Context, args CreateArgs) (secret string, err error)
- // ReadLink finds the creation parameters for the given secret. ErrLinkNotFound is returned if the secret is not
- // found or was deleted/expired. This will automatically expire the link.
- ReadLink(ctx context.Context, secret string) (ReadResponse, error) // TODO Use visited and expiration.
+ // MagicLinkCreate creates a secret for the given parameters and stores the pair. The secret is returned to the
+ // caller.
+ MagicLinkCreate(ctx context.Context, params CreateParams) (secret string, err error)
+ // MagicLinkRead finds the creation parameters for the given secret. ErrLinkNotFound is returned if the secret is
+ // not found or was deleted/expired. This will automatically expire the link.
+ MagicLinkRead(ctx context.Context, secret string) (ReadResult, error)
}
var _ Storage = &memoryMagicLink{}
type memoryMagicLink struct {
- links map[string]ReadResponse
+ links map[string]ReadResult
mux sync.Mutex
}
// NewMemoryStorage creates an in-memory implementation of the MagicLink Storage.
func NewMemoryStorage() Storage {
return &memoryMagicLink{
- links: map[string]ReadResponse{},
+ links: map[string]ReadResult{},
}
}
-func (m *memoryMagicLink) CreateLink(_ context.Context, args CreateArgs) (secret string, err error) {
+func (m *memoryMagicLink) MagicLinkCreate(_ context.Context, args CreateParams) (secret string, err error) {
m.mux.Lock()
defer m.mux.Unlock()
u, err := uuid.NewRandom()
@@ -41,18 +42,18 @@ func (m *memoryMagicLink) CreateLink(_ context.Context, args CreateArgs) (secret
return "", fmt.Errorf("failed to generate UUID as secret: %w", err)
}
secret = u.String()
- response := ReadResponse{
- CreateArgs: args,
+ response := ReadResult{
+ CreateParams: args,
}
m.links[secret] = response
return secret, nil
}
-func (m *memoryMagicLink) ReadLink(_ context.Context, secret string) (ReadResponse, error) {
+func (m *memoryMagicLink) MagicLinkRead(_ context.Context, secret string) (ReadResult, error) {
m.mux.Lock()
defer m.mux.Unlock()
now := time.Now()
readResp, ok := m.links[secret]
- if !ok || readResp.Visited != nil || readResp.CreateArgs.Expires.Before(now) {
+ if !ok || readResp.Visited != nil || readResp.CreateParams.Expires.Before(now) {
return readResp, ErrLinkNotFound
}
readResp.Visited = mld.Ptr(now)
diff --git a/magiclink/types.go b/magiclink/types.go
index 8fe057c..40f6a49 100644
--- a/magiclink/types.go
+++ b/magiclink/types.go
@@ -9,15 +9,12 @@ import (
"github.com/MicahParks/jwkset"
"github.com/golang-jwt/jwt/v5"
-)
-var (
- // ErrArgs indicates that the given parameters are invalid.
- ErrArgs = errors.New("invalid arguments")
+ mld "github.com/MicahParks/magiclinksdev"
)
-// CreateArgs are the arguments for creating a magic link.
-type CreateArgs struct {
+// CreateParams are the parameters for creating a magic link.
+type CreateParams struct {
// Expires is the time the magic link will expire. Use of this field is REQUIRED for all use cases.
Expires time.Time
@@ -47,21 +44,21 @@ type CreateArgs struct {
RedirectURL *url.URL
}
-// Valid confirms the CreateArgs are valid.
-func (p CreateArgs) Valid() error {
+// Valid confirms the CreateParams are valid.
+func (p CreateParams) Valid() error {
if p.Expires.IsZero() {
- return fmt.Errorf("%w: Expires is required", ErrArgs)
+ return fmt.Errorf("%w: Expires is required", mld.ErrParams)
}
if p.RedirectURL == nil {
- return fmt.Errorf("%w: RedirectURL is required", ErrArgs)
+ return fmt.Errorf("%w: RedirectURL is required", mld.ErrParams)
}
return nil
}
-// ReadResponse is the response after a magic link has been read.
-type ReadResponse struct {
- // CreateArgs are the parameters used to create the magic link.
- CreateArgs CreateArgs
+// ReadResult is the result after a magic link has been read.
+type ReadResult struct {
+ // CreateParams are the parameters used to create the magic link.
+ CreateParams CreateParams
// Visited is the first time the magic link was visited. This is nil if the magic link has not been visited.
Visited *time.Time
}
@@ -92,8 +89,8 @@ var (
ErrMagicLinkRead = errors.New("failed to read the magic link from storage")
)
-// ErrorHandlerArgs are the arguments passed to an ErrorHandler when an error occurs.
-type ErrorHandlerArgs struct {
+// ErrorHandlerParams are the parameters passed to an ErrorHandler when an error occurs.
+type ErrorHandlerParams struct {
Err error
Request *http.Request
SuggestedResponseCode int
@@ -104,21 +101,21 @@ type ErrorHandlerArgs struct {
type ErrorHandler interface {
// Handle consumes an error and writes a response to the given writer. The set of possible errors to check by
// unwrapping with errors.Is is documented above the interface's source code.
- Handle(args ErrorHandlerArgs)
+ Handle(args ErrorHandlerParams)
}
// ErrorHandlerFunc is a function that implements the ErrorHandler interface.
-type ErrorHandlerFunc func(args ErrorHandlerArgs)
+type ErrorHandlerFunc func(args ErrorHandlerParams)
// Handle implements the ErrorHandler interface.
-func (f ErrorHandlerFunc) Handle(args ErrorHandlerArgs) {
+func (f ErrorHandlerFunc) Handle(args ErrorHandlerParams) {
f(args)
}
// Config contains the required assets to create a MagicLink service.
type Config struct {
ErrorHandler ErrorHandler
- JWKS JWKSArgs
+ JWKS JWKSParams
CustomRedirector Redirector
ServiceURL *url.URL
SecretQueryKey string
@@ -128,13 +125,13 @@ type Config struct {
// Valid confirms the Config is valid.
func (c Config) Valid() error {
if c.ServiceURL == nil {
- return fmt.Errorf("%w: include a service URL, this is used to build magic links", ErrArgs)
+ return fmt.Errorf("%w: include a service URL, this is used to build magic links", mld.ErrParams)
}
return nil
}
-// JWKSArgs are the parameters for the MagicLink service's JWK Set.
-type JWKSArgs struct {
+// JWKSParams are the parameters for the MagicLink service's JWK Set.
+type JWKSParams struct {
CacheRefresh time.Duration
Store jwkset.Storage
}
diff --git a/magiclink/types_test.go b/magiclink/types_test.go
index 3d519d8..feae53a 100644
--- a/magiclink/types_test.go
+++ b/magiclink/types_test.go
@@ -6,32 +6,33 @@ import (
"testing"
"time"
+ mld "github.com/MicahParks/magiclinksdev"
"github.com/MicahParks/magiclinksdev/magiclink"
"github.com/MicahParks/magiclinksdev/mldtest"
)
-func TestCreateArgs_Valid(t *testing.T) {
- p := magiclink.CreateArgs{}
+func TestCreateParams_Valid(t *testing.T) {
+ p := magiclink.CreateParams{}
err := p.Valid()
- if !errors.Is(err, magiclink.ErrArgs) {
- t.Errorf("expected error %s, got %s", magiclink.ErrArgs, err)
+ if !errors.Is(err, mld.ErrParams) {
+ t.Errorf("expected error %s, got %s", mld.ErrParams, err)
}
p.Expires = time.Now().Add(mldtest.LinksExpireAfter)
err = p.Valid()
- if !errors.Is(err, magiclink.ErrArgs) {
- t.Errorf("expected error %s, got %s", magiclink.ErrArgs, err)
+ if !errors.Is(err, mld.ErrParams) {
+ t.Errorf("expected error %s, got %s", mld.ErrParams, err)
}
p.Expires = time.Time{}
p.RedirectURL = new(url.URL)
err = p.Valid()
- if !errors.Is(err, magiclink.ErrArgs) {
- t.Errorf("expected error %s, got %s", magiclink.ErrArgs, err)
+ if !errors.Is(err, mld.ErrParams) {
+ t.Errorf("expected error %s, got %s", mld.ErrParams, err)
}
p.RedirectURL = nil
- p = magiclink.CreateArgs{
+ p = magiclink.CreateParams{
Expires: time.Now().Add(mldtest.LinksExpireAfter),
RedirectURL: new(url.URL),
}
@@ -41,11 +42,11 @@ func TestCreateArgs_Valid(t *testing.T) {
}
}
-func TestArgs_Valid(t *testing.T) {
+func TestParams_Valid(t *testing.T) {
p := magiclink.Config{}
err := p.Valid()
- if !errors.Is(err, magiclink.ErrArgs) {
- t.Errorf("expected error %s, got %s", magiclink.ErrArgs, err)
+ if !errors.Is(err, mld.ErrParams) {
+ t.Errorf("expected error %s, got %s", mld.ErrParams, err)
}
p = magiclink.Config{
diff --git a/mldtest/email.go b/mldtest/email.go
index 8fc49e6..95b3a19 100644
--- a/mldtest/email.go
+++ b/mldtest/email.go
@@ -6,18 +6,12 @@ import (
"github.com/MicahParks/magiclinksdev/email"
)
-// ErrorProvider is an email provider that always returns an error.
-type ErrorProvider struct{}
-
-// Send implements the email.Provider interface.
-func (e ErrorProvider) Send(_ context.Context, _ email.Email) error {
- return ErrMLDTest
-}
-
// NopProvider is an email provider that does nothing.
type NopProvider struct{}
-// Send implements the email.Provider interface.
-func (n NopProvider) Send(_ context.Context, _ email.Email) error {
+func (n NopProvider) SendMagicLink(_ context.Context, _ email.Email) error {
+ return nil
+}
+func (n NopProvider) SendOTP(_ context.Context, _ email.Email) error {
return nil
}
diff --git a/mldtest/rlimit.go b/mldtest/rlimit.go
index 2038886..405eb71 100644
--- a/mldtest/rlimit.go
+++ b/mldtest/rlimit.go
@@ -4,14 +4,6 @@ import (
"context"
)
-// ErrorLimiter is a rate limiter that always returns an error.
-type ErrorLimiter struct{}
-
-// Wait implements the rlimit.RateLimiter interface.
-func (e ErrorLimiter) Wait(_ context.Context, _ string) error {
- return ErrMLDTest
-}
-
// NopLimiter is a rate limiter that does nothing.
type NopLimiter struct{}
diff --git a/mldtest/storage.go b/mldtest/storage.go
index dec1e49..c62930f 100644
--- a/mldtest/storage.go
+++ b/mldtest/storage.go
@@ -11,6 +11,7 @@ import (
"github.com/MicahParks/magiclinksdev/magiclink"
"github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/otp"
"github.com/MicahParks/magiclinksdev/storage"
)
@@ -37,7 +38,7 @@ var _ storage.Storage = &testStorage{}
type testStorage struct {
jwk jwkset.JWK
- sa map[uuid.UUID]model.ServiceAccount // TODO Need mutex?
+ sa map[uuid.UUID]model.ServiceAccount
}
func (t *testStorage) toMemory(ctx context.Context) (jwkset.Storage, error) {
@@ -79,10 +80,10 @@ func (t *testStorage) Close(_ context.Context) error {
func (t *testStorage) TestingTruncate(_ context.Context) error {
return nil
}
-func (t *testStorage) CreateAdminSA(_ context.Context, _ model.ValidAdminCreateArgs) error {
+func (t *testStorage) SAAdminCreate(_ context.Context, _ model.ValidAdminCreateParams) error {
return nil
}
-func (t *testStorage) CreateSA(_ context.Context, _ model.ValidServiceAccountCreateArgs) (model.ServiceAccount, error) {
+func (t *testStorage) SACreate(_ context.Context, _ model.ValidServiceAccountCreateParams) (model.ServiceAccount, error) {
u := uuid.New()
apiKey := uuid.New()
aud := uuid.New()
@@ -95,14 +96,14 @@ func (t *testStorage) CreateSA(_ context.Context, _ model.ValidServiceAccountCre
t.sa[u] = sa
return sa, nil
}
-func (t *testStorage) ReadSA(_ context.Context, u uuid.UUID) (model.ServiceAccount, error) {
+func (t *testStorage) SARead(_ context.Context, u uuid.UUID) (model.ServiceAccount, error) {
sa, ok := t.sa[u]
if !ok {
return model.ServiceAccount{}, storage.ErrNotFound
}
return sa, nil
}
-func (t *testStorage) ReadSAFromAPIKey(_ context.Context, apiKey uuid.UUID) (model.ServiceAccount, error) {
+func (t *testStorage) SAReadFromAPIKey(_ context.Context, apiKey uuid.UUID) (model.ServiceAccount, error) {
for _, sa := range t.sa {
if sa.APIKey == apiKey {
return sa, nil
@@ -110,13 +111,13 @@ func (t *testStorage) ReadSAFromAPIKey(_ context.Context, apiKey uuid.UUID) (mod
}
return model.ServiceAccount{}, fmt.Errorf("no service account found with API key %w", storage.ErrNotFound)
}
-func (t *testStorage) ReadSigningKey(_ context.Context, _ storage.ReadSigningKeyOptions) (meta jwkset.JWK, err error) {
+func (t *testStorage) SigningKeyRead(_ context.Context, _ storage.ReadSigningKeyOptions) (meta jwkset.JWK, err error) {
return t.jwk, nil
}
-func (t *testStorage) ReadDefaultSigningKey(_ context.Context) (jwk jwkset.JWK, err error) {
+func (t *testStorage) SigningKeyDefaultRead(_ context.Context) (jwk jwkset.JWK, err error) {
return t.jwk, nil
}
-func (t *testStorage) UpdateDefaultSigningKey(_ context.Context, _ string) error {
+func (t *testStorage) SigningKeyDefaultUpdate(_ context.Context, _ string) error {
return nil
}
func (t *testStorage) KeyDelete(_ context.Context, _ string) (ok bool, err error) {
@@ -173,58 +174,15 @@ func (t *testStorage) MarshalWithOptions(ctx context.Context, marshalOptions jwk
}
return m.MarshalWithOptions(ctx, marshalOptions, validationOptions)
}
-func (t *testStorage) CreateLink(_ context.Context, _ magiclink.CreateArgs) (secret string, err error) {
+func (t *testStorage) MagicLinkCreate(_ context.Context, _ magiclink.CreateParams) (secret string, err error) {
return uuid.New().String(), nil
}
-func (t *testStorage) ReadLink(_ context.Context, _ string) (magiclink.ReadResponse, error) {
- return magiclink.ReadResponse{}, nil
+func (t *testStorage) MagicLinkRead(_ context.Context, _ string) (magiclink.ReadResult, error) {
+ return magiclink.ReadResult{}, nil
}
-
-// ErrorStorage is a storage.Storage implementation that always returns an error.
-type ErrorStorage struct{}
-
-func (e ErrorStorage) Begin(_ context.Context) (storage.Tx, error) {
- return nil, ErrMLDTest
-}
-func (e ErrorStorage) Close(_ context.Context) error {
- return ErrMLDTest
-}
-func (e ErrorStorage) TestingTruncate(_ context.Context) error {
- return ErrMLDTest
-}
-func (e ErrorStorage) CreateAdminSA(_ context.Context, _ model.ValidAdminCreateArgs) error {
- return ErrMLDTest
-}
-func (e ErrorStorage) CreateSA(_ context.Context, _ model.ValidServiceAccountCreateArgs) (model.ServiceAccount, error) {
- return model.ServiceAccount{}, ErrMLDTest
-}
-func (e ErrorStorage) ReadSA(_ context.Context, _ uuid.UUID) (model.ServiceAccount, error) {
- return model.ServiceAccount{}, ErrMLDTest
-}
-func (e ErrorStorage) ReadSAFromAPIKey(_ context.Context, _ uuid.UUID) (model.ServiceAccount, error) {
- return model.ServiceAccount{}, ErrMLDTest
+func (t *testStorage) OTPCreate(_ context.Context, _ otp.CreateParams) (otp.CreateResult, error) {
+ return otp.CreateResult{}, nil
}
-func (e ErrorStorage) ReadSigningKey(_ context.Context, _ storage.ReadSigningKeyOptions) (meta jwkset.JWK, err error) {
- return jwkset.JWK{}, ErrMLDTest
-}
-func (e ErrorStorage) UpdateDefaultSigningKey(_ context.Context, _ string) error {
- return ErrMLDTest
-}
-func (e ErrorStorage) DeleteKey(_ context.Context, _ string) (ok bool, err error) {
- return true, ErrMLDTest
-}
-func (e ErrorStorage) ReadKey(_ context.Context, _ string) (jwkset.JWK, error) {
- return jwkset.JWK{}, ErrMLDTest
-}
-func (e ErrorStorage) SnapshotKeys(_ context.Context) ([]jwkset.JWK, error) {
- return nil, ErrMLDTest
-}
-func (e ErrorStorage) WriteKey(_ context.Context, _ jwkset.JWK) error {
- return ErrMLDTest
-}
-func (e ErrorStorage) CreateLink(_ context.Context, _ magiclink.CreateArgs) (secret string, err error) {
- return "", ErrMLDTest
-}
-func (e ErrorStorage) ReadLink(_ context.Context, _ string) (magiclink.ReadResponse, error) {
- return magiclink.ReadResponse{}, ErrMLDTest
+func (t *testStorage) OTPValidate(_ context.Context, _, _ string) error {
+ return nil
}
diff --git a/mldtest/util.go b/mldtest/util.go
index 62330d9..ad724ac 100644
--- a/mldtest/util.go
+++ b/mldtest/util.go
@@ -10,7 +10,7 @@ import (
const (
// BaseURL is the test base URL for the service.
- BaseURL = "http://localhost:8080/api/v1/"
+ BaseURL = "http://localhost:8080/api/v2/"
// Iss is the test issuer for the service.
Iss = BaseURL
// LogoImageURL is the test service logo for the service.
diff --git a/model/admin_create.go b/model/admin_create.go
index 28b852a..0ab6fe1 100644
--- a/model/admin_create.go
+++ b/model/admin_create.go
@@ -6,33 +6,30 @@ import (
"github.com/google/uuid"
)
-// AdminCreateArgs are the unvalidated arguments for creating an admin.
-type AdminCreateArgs struct {
- APIKey uuid.UUID `json:"apiKey"`
- Aud uuid.UUID `json:"aud"`
- UUID uuid.UUID `json:"uuid"`
- ServiceAccountCreateArgs ServiceAccountCreateArgs `json:"serviceAccountCreateArgs"`
+type AdminCreateParams struct {
+ APIKey uuid.UUID `json:"apiKey"`
+ Aud uuid.UUID `json:"aud"`
+ UUID uuid.UUID `json:"uuid"`
+ ServiceAccountCreateParams ServiceAccountCreateParams `json:"serviceAccountCreateParams"`
}
-// Validate validates the admin create arguments.
-func (a AdminCreateArgs) Validate(config Validation) (ValidAdminCreateArgs, error) {
- saArgs, err := a.ServiceAccountCreateArgs.Validate(config)
+func (a AdminCreateParams) Validate(config Validation) (ValidAdminCreateParams, error) {
+ saParams, err := a.ServiceAccountCreateParams.Validate(config)
if err != nil {
- return ValidAdminCreateArgs{}, fmt.Errorf("failed to validate service account args: %w", err)
+ return ValidAdminCreateParams{}, fmt.Errorf("failed to validate service account args: %w", err)
}
- valid := ValidAdminCreateArgs{
- APIKey: a.APIKey,
- Aud: a.Aud,
- UUID: a.UUID,
- ValidServiceAccountCreateArgs: saArgs,
+ valid := ValidAdminCreateParams{
+ APIKey: a.APIKey,
+ Aud: a.Aud,
+ UUID: a.UUID,
+ ValidServiceAccountCreateParams: saParams,
}
return valid, nil
}
-// ValidAdminCreateArgs are the validated arguments for creating an admin.
-type ValidAdminCreateArgs struct {
- APIKey uuid.UUID
- Aud uuid.UUID
- UUID uuid.UUID
- ValidServiceAccountCreateArgs ValidServiceAccountCreateArgs
+type ValidAdminCreateParams struct {
+ APIKey uuid.UUID
+ Aud uuid.UUID
+ UUID uuid.UUID
+ ValidServiceAccountCreateParams ValidServiceAccountCreateParams
}
diff --git a/model/email_link_create.go b/model/email_link_create.go
deleted file mode 100644
index cd6f55a..0000000
--- a/model/email_link_create.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package model
-
-import (
- "fmt"
- "net/mail"
- "unicode/utf8"
-)
-
-// EmailLinkCreateArgs are the unvalidated arguments for creating an email link.
-type EmailLinkCreateArgs struct {
- ButtonText string `json:"buttonText"`
- Greeting string `json:"greeting"`
- LogoClickURL string `json:"logoClickURL"`
- LogoImageURL string `json:"logoImageURL"`
- ServiceName string `json:"serviceName"`
- Subject string `json:"subject"`
- SubTitle string `json:"subTitle"`
- Title string `json:"title"`
- ToEmail string `json:"toEmail"`
- ToName string `json:"toName"`
-}
-
-// Validate implements the Validatable interface.
-func (p EmailLinkCreateArgs) Validate(config Validation) (ValidEmailLinkCreateArgs, error) {
- if p.ButtonText == "" {
- p.ButtonText = "Magic link"
- }
- if p.LogoImageURL != "" {
- u, err := httpURL(config, p.LogoClickURL)
- if err != nil {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("failed to parse logo click URL: %w", err)
- }
- p.LogoClickURL = u.String()
- u, err = httpURL(config, p.LogoImageURL)
- if err != nil {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("failed to parse logo image URL: %w", err)
- }
- p.LogoImageURL = u.String()
- } else {
- p.LogoClickURL = ""
- }
- runeCount := uint(utf8.RuneCountInString(p.ServiceName))
- if runeCount < config.ServiceNameMinUTF8 || runeCount > config.ServiceNameMaxUTF8 {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("%w: service name must be between %d and %d UTF8 runes", ErrInvalidModel, config.ServiceNameMinUTF8, config.ServiceNameMaxUTF8)
- }
- if len(p.Subject) < 5 || len(p.Subject) > 100 {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("%w: subject must be between 5 and 100 characters", ErrInvalidModel)
- }
- if len(p.Title) < 5 || len(p.Title) > 256 {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("%w: title must be between 5 and 256 characters", ErrInvalidModel)
- }
- address, err := mail.ParseAddress(p.ToEmail)
- if err != nil {
- return ValidEmailLinkCreateArgs{}, fmt.Errorf("failed to parse email address: %w", err)
- }
- address.Name = p.ToName
- valid := ValidEmailLinkCreateArgs{
- ButtonText: p.ButtonText,
- Greeting: p.Greeting,
- LogoClickURL: p.LogoClickURL,
- LogoImageURL: p.LogoImageURL,
- ServiceName: p.ServiceName,
- Subject: p.Subject,
- SubTitle: p.SubTitle,
- Title: p.Title,
- ToEmail: address,
- }
- return valid, nil
-}
-
-// ValidEmailLinkCreateArgs are the validated arguments for creating an email link.
-type ValidEmailLinkCreateArgs struct {
- ButtonText string
- Greeting string
- LogoClickURL string
- LogoImageURL string
- ServiceName string
- Subject string
- SubTitle string
- Title string
- ToEmail *mail.Address
-}
-
-// EmailLinkCreateRequest is the unvalidated request to create an email link.
-type EmailLinkCreateRequest struct {
- EmailArgs EmailLinkCreateArgs `json:"emailArgs"`
- LinkArgs LinkCreateArgs `json:"linkArgs"`
-}
-
-// Validate implements the Validatable interface.
-func (b EmailLinkCreateRequest) Validate(config Validation) (ValidEmailLinkCreateRequest, error) {
- emailArgs, err := b.EmailArgs.Validate(config)
- if err != nil {
- return ValidEmailLinkCreateRequest{}, fmt.Errorf("failed to validate email args: %w", err)
- }
- linkArgs, err := b.LinkArgs.Validate(config)
- if err != nil {
- return ValidEmailLinkCreateRequest{}, fmt.Errorf("failed to validate link args: %w", err)
- }
- valid := ValidEmailLinkCreateRequest{
- EmailArgs: emailArgs,
- LinkArgs: linkArgs,
- }
- return valid, nil
-}
-
-// ValidEmailLinkCreateRequest is the validated request to create an email link.
-type ValidEmailLinkCreateRequest struct {
- EmailArgs ValidEmailLinkCreateArgs
- LinkArgs ValidLinkCreateArgs
-}
-
-// EmailLinkCreateResults are the results of creating an email link.
-type EmailLinkCreateResults struct {
- LinkCreateResults LinkCreateResults `json:"linkCreateResults"`
-}
-
-// EmailLinkCreateResponse is the response to creating an email link.
-type EmailLinkCreateResponse struct {
- EmailLinkCreateResults EmailLinkCreateResults `json:"emailLinkCreateResults"`
- RequestMetadata RequestMetadata `json:"requestMetadata"`
-}
diff --git a/model/jwt_create.go b/model/jwt_create.go
index 3339e39..e3cd281 100644
--- a/model/jwt_create.go
+++ b/model/jwt_create.go
@@ -6,70 +6,62 @@ import (
"time"
)
-// JWTCreateArgs are the unvalidated arguments for creating a JWT.
-type JWTCreateArgs struct {
- JWTAlg string `json:"jwtAlg"`
- JWTClaims any `json:"jwtClaims"`
- JWTLifespanSeconds int `json:"jwtLifespanSeconds"`
+type JWTCreateParams struct {
+ Alg string `json:"alg"`
+ Claims any `json:"claims"`
+ LifespanSeconds int `json:"lifespanSeconds"`
}
-// Validate implements the Validatable interface.
-func (j JWTCreateArgs) Validate(config Validation) (ValidJWTCreateArgs, error) {
- marshaled, err := json.Marshal(j.JWTClaims)
+func (j JWTCreateParams) Validate(config Validation) (ValidJWTCreateParams, error) {
+ marshaled, err := json.Marshal(j.Claims)
if err != nil {
- return ValidJWTCreateArgs{}, fmt.Errorf("failed to JSON marshal claims: %w", err)
+ return ValidJWTCreateParams{}, fmt.Errorf("failed to JSON marshal claims: %w", err)
}
- lifespan := time.Duration(j.JWTLifespanSeconds) * time.Second
+ lifespan := time.Duration(j.LifespanSeconds) * time.Second
if lifespan == 0 {
lifespan = 5 * time.Minute
} else if lifespan < 5*time.Second || lifespan > config.JWTLifespanMax.Get() {
- return ValidJWTCreateArgs{}, fmt.Errorf("%w: JWT lifespan seconds must be between 5 seconds and %d", ErrInvalidModel, int(config.JWTLifespanMax.Get().Seconds()))
+ return ValidJWTCreateParams{}, fmt.Errorf("%w: JWT lifespan seconds must be between 5 seconds and %d", ErrInvalidModel, int(config.JWTLifespanMax.Get().Seconds()))
}
if uint(len(marshaled)) > config.JWTClaimsMaxBytes {
- return ValidJWTCreateArgs{}, fmt.Errorf("%w: JWT claims must be less than %d bytes", ErrInvalidModel, config.JWTClaimsMaxBytes)
+ return ValidJWTCreateParams{}, fmt.Errorf("%w: JWT claims must be less than %d bytes", ErrInvalidModel, config.JWTClaimsMaxBytes)
}
- valid := ValidJWTCreateArgs{
- JWTAlg: j.JWTAlg,
- JWTClaims: marshaled,
- JWTLifespan: lifespan,
+ valid := ValidJWTCreateParams{
+ Alg: j.Alg,
+ Claims: marshaled,
+ Lifespan: lifespan,
}
return valid, nil
}
-// ValidJWTCreateArgs are the validated arguments for creating a JWT.
-type ValidJWTCreateArgs struct {
- JWTAlg string
- JWTClaims json.RawMessage
- JWTLifespan time.Duration
+type ValidJWTCreateParams struct {
+ Alg string
+ Claims json.RawMessage
+ Lifespan time.Duration
}
-// JWTCreateRequest is the unvalidated request to create a JWT.
type JWTCreateRequest struct {
- JWTCreateArgs JWTCreateArgs `json:"jwtCreateArgs"`
+ JWTCreateParams JWTCreateParams `json:"jwtCreateParams"`
}
-// Validate implements the Validatable interface.
func (j JWTCreateRequest) Validate(config Validation) (ValidJWTCreateRequest, error) {
- valid, err := j.JWTCreateArgs.Validate(config)
+ valid, err := j.JWTCreateParams.Validate(config)
if err != nil {
return ValidJWTCreateRequest{}, fmt.Errorf("failed to validate JWT create args: %w", err)
}
return ValidJWTCreateRequest{
- JWTCreateArgs: valid,
+ JWTCreateParams: valid,
}, nil
}
-// ValidJWTCreateRequest is the validated request to create a JWT.
type ValidJWTCreateRequest struct {
- JWTCreateArgs ValidJWTCreateArgs
+ JWTCreateParams ValidJWTCreateParams
}
-// JWTCreateResults are the results of creating a JWT.
type JWTCreateResults struct {
JWT string `json:"jwt"`
}
-// JWTCreateResponse is the response to creating a JWT.
type JWTCreateResponse struct {
JWTCreateResults JWTCreateResults `json:"jwtCreateResults"`
RequestMetadata RequestMetadata `json:"requestMetadata"`
diff --git a/model/jwt_validate.go b/model/jwt_validate.go
index 383d45c..a14ce58 100644
--- a/model/jwt_validate.go
+++ b/model/jwt_validate.go
@@ -5,49 +5,41 @@ import (
"fmt"
)
-// JWTValidateArgs are the unvalidated arguments for validating a JWT.
-type JWTValidateArgs struct {
+type JWTValidateParams struct {
JWT string `json:"jwt"`
}
-// Validate implements the Validatable interface.
-func (j JWTValidateArgs) Validate(_ Validation) (ValidJWTValidateArgs, error) {
- valid := ValidJWTValidateArgs(j)
+func (j JWTValidateParams) Validate(_ Validation) (ValidJWTValidateParams, error) {
+ valid := ValidJWTValidateParams(j)
return valid, nil
}
-// ValidJWTValidateArgs are the validated arguments for validating a JWT.
-type ValidJWTValidateArgs struct {
+type ValidJWTValidateParams struct {
JWT string
}
-// JWTValidateRequest is the unvalidated request to validate a JWT.
type JWTValidateRequest struct {
- JWTValidateArgs JWTValidateArgs `json:"jwtValidateArgs"`
+ JWTValidateParams JWTValidateParams `json:"jwtValidateParams"`
}
-// Validate implements the Validatable interface.
func (j JWTValidateRequest) Validate(config Validation) (ValidJWTValidateRequest, error) {
- valid, err := j.JWTValidateArgs.Validate(config)
+ valid, err := j.JWTValidateParams.Validate(config)
if err != nil {
return ValidJWTValidateRequest{}, fmt.Errorf("failed to validate JWT validate args: %w", err)
}
return ValidJWTValidateRequest{
- JWTValidateArgs: valid,
+ JWTValidateParams: valid,
}, nil
}
-// ValidJWTValidateRequest is the validated request to validate a JWT.
type ValidJWTValidateRequest struct {
- JWTValidateArgs ValidJWTValidateArgs
+ JWTValidateParams ValidJWTValidateParams
}
-// JWTValidateResults are the results of validating a JWT.
type JWTValidateResults struct {
JWTClaims json.RawMessage `json:"claims"`
}
-// JWTValidateResponse is the response to validating a JWT.
type JWTValidateResponse struct {
JWTValidateResults JWTValidateResults `json:"jwtValidateResults"`
RequestMetadata RequestMetadata `json:"requestMetadata"`
diff --git a/model/link_create.go b/model/link_create.go
deleted file mode 100644
index 44d318b..0000000
--- a/model/link_create.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package model
-
-import (
- "fmt"
- "net/url"
- "time"
-
- "github.com/MicahParks/magiclinksdev/magiclink"
-)
-
-// LinkCreateArgs are the unvalidated arguments for creating a link.
-type LinkCreateArgs struct {
- JWTCreateArgs JWTCreateArgs `json:"jwtCreateArgs"`
- LinkLifespan int `json:"linkLifespan"`
- RedirectQueryKey string `json:"redirectQueryKey"`
- RedirectURL string `json:"redirectUrl"`
-}
-
-// Validate validates the link create arguments.
-func (p LinkCreateArgs) Validate(config Validation) (ValidLinkCreateArgs, error) {
- validJWTCreateArgs, err := p.JWTCreateArgs.Validate(config)
- if err != nil {
- return ValidLinkCreateArgs{}, fmt.Errorf("failed to validate JWT create args: %w", err)
- }
- lifespan := time.Duration(p.LinkLifespan) * time.Second
- if lifespan == 0 {
- lifespan = time.Hour
- } else if lifespan < 5*time.Second || lifespan > config.LinkLifespanMax.Get() {
- return ValidLinkCreateArgs{}, fmt.Errorf("%w: link lifespan must be between 5 and %d", ErrInvalidModel, int(config.LinkLifespanMax.Get().Seconds()))
- }
-
- if p.RedirectQueryKey == "" {
- p.RedirectQueryKey = magiclink.DefaultRedirectQueryKey
- }
- u, err := httpURL(config, p.RedirectURL)
- if err != nil {
- return ValidLinkCreateArgs{}, fmt.Errorf("failed to validate URL: %w", err)
- }
- valid := ValidLinkCreateArgs{
- LinkLifespan: lifespan,
- JWTCreateArgs: validJWTCreateArgs,
- RedirectQueryKey: p.RedirectQueryKey,
- RedirectURL: u,
- }
- return valid, nil
-}
-
-// ValidLinkCreateArgs are the validated arguments for creating a link.
-type ValidLinkCreateArgs struct {
- LinkLifespan time.Duration
- JWTCreateArgs ValidJWTCreateArgs
- RedirectQueryKey string
- RedirectURL *url.URL
-}
-
-// LinkCreateRequest is the request to create a link.
-type LinkCreateRequest struct {
- LinkArgs LinkCreateArgs `json:"linkArgs"`
-}
-
-// Validate validates the link create request.
-func (b LinkCreateRequest) Validate(config Validation) (ValidLinkCreateRequest, error) {
- linkArgs, err := b.LinkArgs.Validate(config)
- if err != nil {
- return ValidLinkCreateRequest{}, fmt.Errorf("failed to validate link args: %w", err)
- }
- valid := ValidLinkCreateRequest{
- LinkArgs: linkArgs,
- }
- return valid, nil
-}
-
-// ValidLinkCreateRequest is the validated request to create a link.
-type ValidLinkCreateRequest struct {
- LinkArgs ValidLinkCreateArgs
-}
-
-// LinkCreateResults are the results of creating a link.
-type LinkCreateResults struct {
- MagicLink string `json:"magicLink"`
- Secret string `json:"secret"`
-}
-
-// LinkCreateResponse is the response to creating a link.
-type LinkCreateResponse struct {
- LinkCreateResults LinkCreateResults `json:"linkCreateResults"`
- RequestMetadata RequestMetadata `json:"requestMetadata"`
-}
diff --git a/model/magic_link_create.go b/model/magic_link_create.go
new file mode 100644
index 0000000..b987983
--- /dev/null
+++ b/model/magic_link_create.go
@@ -0,0 +1,79 @@
+package model
+
+import (
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/MicahParks/magiclinksdev/magiclink"
+)
+
+type MagicLinkCreateParams struct {
+ JWTCreateParams JWTCreateParams `json:"jwtCreateParams"`
+ LifespanSeconds int `json:"lifespanSeconds"`
+ RedirectQueryKey string `json:"redirectQueryKey"`
+ RedirectURL string `json:"redirectURL"`
+}
+
+func (p MagicLinkCreateParams) Validate(config Validation) (ValidMagicLinkCreateParams, error) {
+ validJWTCreateParams, err := p.JWTCreateParams.Validate(config)
+ if err != nil {
+ return ValidMagicLinkCreateParams{}, fmt.Errorf("failed to validate JWT create args: %w", err)
+ }
+ lifespan := time.Duration(p.LifespanSeconds) * time.Second
+ if lifespan == 0 {
+ lifespan = time.Hour
+ } else if lifespan < 5*time.Second || lifespan > config.LifeSpanSeconds.Get() {
+ return ValidMagicLinkCreateParams{}, fmt.Errorf("%w: link lifespan must be between 5 and %d", ErrInvalidModel, int(config.LifeSpanSeconds.Get().Seconds()))
+ }
+ if p.RedirectQueryKey == "" {
+ p.RedirectQueryKey = magiclink.DefaultRedirectQueryKey
+ }
+ u, err := httpURL(config, p.RedirectURL)
+ if err != nil {
+ return ValidMagicLinkCreateParams{}, fmt.Errorf("failed to validate URL: %w", err)
+ }
+ valid := ValidMagicLinkCreateParams{
+ Lifespan: lifespan,
+ JWTCreateParams: validJWTCreateParams,
+ RedirectQueryKey: p.RedirectQueryKey,
+ RedirectURL: u,
+ }
+ return valid, nil
+}
+
+type ValidMagicLinkCreateParams struct {
+ Lifespan time.Duration
+ JWTCreateParams ValidJWTCreateParams
+ RedirectQueryKey string
+ RedirectURL *url.URL
+}
+
+type MagicLinkCreateRequest struct {
+ MagicLinkCreateParams MagicLinkCreateParams `json:"magicLinkCreateParams"`
+}
+
+func (b MagicLinkCreateRequest) Validate(config Validation) (ValidMagicLinkCreateRequest, error) {
+ magicLinkParams, err := b.MagicLinkCreateParams.Validate(config)
+ if err != nil {
+ return ValidMagicLinkCreateRequest{}, fmt.Errorf("failed to validate magic link args: %w", err)
+ }
+ valid := ValidMagicLinkCreateRequest{
+ MagicLinkParams: magicLinkParams,
+ }
+ return valid, nil
+}
+
+type ValidMagicLinkCreateRequest struct {
+ MagicLinkParams ValidMagicLinkCreateParams
+}
+
+type MagicLinkCreateResults struct {
+ MagicLink string `json:"magicLink"`
+ Secret string `json:"secret"`
+}
+
+type MagicLinkCreateResponse struct {
+ MagicLinkCreateResults MagicLinkCreateResults `json:"magicLinkCreateResults"`
+ RequestMetadata RequestMetadata `json:"requestMetadata"`
+}
diff --git a/model/magic_link_email_create.go b/model/magic_link_email_create.go
new file mode 100644
index 0000000..245afb6
--- /dev/null
+++ b/model/magic_link_email_create.go
@@ -0,0 +1,114 @@
+package model
+
+import (
+ "fmt"
+ "net/mail"
+ "unicode/utf8"
+)
+
+type MagicLinkEmailCreateParams struct {
+ ButtonText string `json:"buttonText"`
+ Greeting string `json:"greeting"`
+ LogoClickURL string `json:"logoClickURL"`
+ LogoImageURL string `json:"logoImageURL"`
+ ServiceName string `json:"serviceName"`
+ Subject string `json:"subject"`
+ SubTitle string `json:"subTitle"`
+ Title string `json:"title"`
+ ToEmail string `json:"toEmail"`
+ ToName string `json:"toName"`
+}
+
+func (p MagicLinkEmailCreateParams) Validate(config Validation) (ValidMagicLinkEmailCreateParams, error) {
+ if p.ButtonText == "" {
+ p.ButtonText = "Magic link"
+ }
+ if p.LogoImageURL != "" {
+ u, err := httpURL(config, p.LogoClickURL)
+ if err != nil {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("failed to parse logo click URL: %w", err)
+ }
+ p.LogoClickURL = u.String()
+ u, err = httpURL(config, p.LogoImageURL)
+ if err != nil {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("failed to parse logo image URL: %w", err)
+ }
+ p.LogoImageURL = u.String()
+ } else {
+ p.LogoClickURL = ""
+ }
+ runeCount := uint(utf8.RuneCountInString(p.ServiceName))
+ if runeCount < config.ServiceNameMinUTF8 || runeCount > config.ServiceNameMaxUTF8 {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("%w: service name must be between %d and %d UTF8 runes", ErrInvalidModel, config.ServiceNameMinUTF8, config.ServiceNameMaxUTF8)
+ }
+ if len(p.Subject) < 5 || len(p.Subject) > 100 {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("%w: subject must be between 5 and 100 characters", ErrInvalidModel)
+ }
+ if len(p.Title) < 5 || len(p.Title) > 256 {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("%w: title must be between 5 and 256 characters", ErrInvalidModel)
+ }
+ address, err := mail.ParseAddress(p.ToEmail)
+ if err != nil {
+ return ValidMagicLinkEmailCreateParams{}, fmt.Errorf("failed to parse email address: %w", err)
+ }
+ address.Name = p.ToName
+ valid := ValidMagicLinkEmailCreateParams{
+ ButtonText: p.ButtonText,
+ Greeting: p.Greeting,
+ LogoClickURL: p.LogoClickURL,
+ LogoImageURL: p.LogoImageURL,
+ ServiceName: p.ServiceName,
+ Subject: p.Subject,
+ SubTitle: p.SubTitle,
+ Title: p.Title,
+ ToEmail: address,
+ }
+ return valid, nil
+}
+
+type ValidMagicLinkEmailCreateParams struct {
+ ButtonText string
+ Greeting string
+ LogoClickURL string
+ LogoImageURL string
+ ServiceName string
+ Subject string
+ SubTitle string
+ Title string
+ ToEmail *mail.Address
+}
+
+type MagicLinkEmailCreateRequest struct {
+ MagicLinkCreateParams MagicLinkCreateParams `json:"magicLinkCreateParams"`
+ MagicLinkEmailCreateParams MagicLinkEmailCreateParams `json:"magicLinkEmailCreateParams"`
+}
+
+func (b MagicLinkEmailCreateRequest) Validate(config Validation) (ValidMagicLinkEmailCreateRequest, error) {
+ magicLinkEmailCreateParams, err := b.MagicLinkEmailCreateParams.Validate(config)
+ if err != nil {
+ return ValidMagicLinkEmailCreateRequest{}, fmt.Errorf("failed to validate email params: %w", err)
+ }
+ magicLinkCreateParams, err := b.MagicLinkCreateParams.Validate(config)
+ if err != nil {
+ return ValidMagicLinkEmailCreateRequest{}, fmt.Errorf("failed to validate link params: %w", err)
+ }
+ valid := ValidMagicLinkEmailCreateRequest{
+ MagicLinkCreateParams: magicLinkCreateParams,
+ MagicLinkEmailCreateParams: magicLinkEmailCreateParams,
+ }
+ return valid, nil
+}
+
+type ValidMagicLinkEmailCreateRequest struct {
+ MagicLinkCreateParams ValidMagicLinkCreateParams
+ MagicLinkEmailCreateParams ValidMagicLinkEmailCreateParams
+}
+
+type MagicLinkEmailCreateResults struct {
+ MagicLinkCreateResults MagicLinkCreateResults `json:"magicLinkCreateResults"`
+}
+
+type MagicLinkEmailCreateResponse struct {
+ MagicLinkEmailCreateResults MagicLinkEmailCreateResults `json:"magicLinkEmailCreateResults"`
+ RequestMetadata RequestMetadata `json:"requestMetadata"`
+}
diff --git a/model/otp_create.go b/model/otp_create.go
new file mode 100644
index 0000000..b18d235
--- /dev/null
+++ b/model/otp_create.go
@@ -0,0 +1,79 @@
+package model
+
+import (
+ "fmt"
+ "time"
+
+ mld "github.com/MicahParks/magiclinksdev"
+)
+
+type OTPCreateParams struct {
+ CharSetAlphaLower bool `json:"charSetAlphaLower"`
+ CharSetAlphaUpper bool `json:"charSetAlphaUpper"`
+ CharSetNumeric bool `json:"charSetNumeric"`
+ Length uint `json:"length"`
+ LifespanSeconds int `json:"lifespanSeconds"`
+}
+
+func (o OTPCreateParams) Validate(config Validation) (ValidOTPCreateParams, error) {
+ if !o.CharSetAlphaLower && !o.CharSetAlphaUpper && !o.CharSetNumeric {
+ return ValidOTPCreateParams{}, fmt.Errorf("%w: at least one character set must be selected", ErrInvalidModel)
+ }
+ length := o.Length
+ if length == 0 {
+ length = mld.DefaultOTPLength
+ } else if length < 1 || length > 12 { // Limited by email template.
+ return ValidOTPCreateParams{}, fmt.Errorf("%w: link length must be between 1 and 12", ErrInvalidModel)
+ }
+ lifespan := time.Duration(o.LifespanSeconds) * time.Second
+ if lifespan == 0 {
+ lifespan = time.Hour
+ } else if lifespan < 5*time.Second || lifespan > config.LifeSpanSeconds.Get() {
+ return ValidOTPCreateParams{}, fmt.Errorf("%w: link lifespan must be between 5 and %d", ErrInvalidModel, int(config.LifeSpanSeconds.Get().Seconds()))
+ }
+ valid := ValidOTPCreateParams{
+ CharSetAlphaLower: o.CharSetAlphaLower,
+ CharSetAlphaUpper: o.CharSetAlphaUpper,
+ CharSetNumeric: o.CharSetNumeric,
+ Length: length,
+ Lifespan: lifespan,
+ }
+ return valid, nil
+}
+
+type ValidOTPCreateParams struct {
+ CharSetAlphaLower bool
+ CharSetAlphaUpper bool
+ CharSetNumeric bool
+ Length uint
+ Lifespan time.Duration
+}
+
+type OTPCreateRequest struct {
+ OTPCreateParams OTPCreateParams `json:"otpCreateParams"`
+}
+
+func (o OTPCreateRequest) Validate(config Validation) (ValidOTPCreateRequest, error) {
+ validOTPCreateParams, err := o.OTPCreateParams.Validate(config)
+ if err != nil {
+ return ValidOTPCreateRequest{}, fmt.Errorf("failed to validate OTP create args: %w", err)
+ }
+ valid := ValidOTPCreateRequest{
+ OTPCreateParams: validOTPCreateParams,
+ }
+ return valid, nil
+}
+
+type ValidOTPCreateRequest struct {
+ OTPCreateParams ValidOTPCreateParams
+}
+
+type OTPCreateResults struct {
+ ID string `json:"id"`
+ OTP string `json:"otp"`
+}
+
+type OTPCreateResponse struct {
+ OTPCreateResults OTPCreateResults `json:"otpCreateResults"`
+ RequestMetadata RequestMetadata `json:"requestMetadata"`
+}
diff --git a/model/otp_email_create.go b/model/otp_email_create.go
new file mode 100644
index 0000000..0c6b864
--- /dev/null
+++ b/model/otp_email_create.go
@@ -0,0 +1,108 @@
+package model
+
+import (
+ "fmt"
+ "net/mail"
+ "unicode/utf8"
+)
+
+type OTPEmailCreateParams struct {
+ Greeting string `json:"greeting"`
+ LogoClickURL string `json:"logoClickURL"`
+ LogoImageURL string `json:"logoImageURL"`
+ ServiceName string `json:"serviceName"`
+ Subject string `json:"subject"`
+ SubTitle string `json:"subTitle"`
+ Title string `json:"title"`
+ ToEmail string `json:"toEmail"`
+ ToName string `json:"toName"`
+}
+
+func (p OTPEmailCreateParams) Validate(config Validation) (ValidOTPEmailCreateParams, error) {
+ if p.LogoImageURL != "" {
+ u, err := httpURL(config, p.LogoClickURL)
+ if err != nil {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("failed to parse logo click URL: %w", err)
+ }
+ p.LogoClickURL = u.String()
+ u, err = httpURL(config, p.LogoImageURL)
+ if err != nil {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("failed to parse logo image URL: %w", err)
+ }
+ p.LogoImageURL = u.String()
+ } else {
+ p.LogoClickURL = ""
+ }
+ runeCount := uint(utf8.RuneCountInString(p.ServiceName))
+ if runeCount < config.ServiceNameMinUTF8 || runeCount > config.ServiceNameMaxUTF8 {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("%w: service name must be between %d and %d UTF8 runes", ErrInvalidModel, config.ServiceNameMinUTF8, config.ServiceNameMaxUTF8)
+ }
+ if len(p.Subject) < 5 || len(p.Subject) > 100 {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("%w: subject must be between 5 and 100 characters", ErrInvalidModel)
+ }
+ if len(p.Title) < 5 || len(p.Title) > 256 {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("%w: title must be between 5 and 256 characters", ErrInvalidModel)
+ }
+ address, err := mail.ParseAddress(p.ToEmail)
+ if err != nil {
+ return ValidOTPEmailCreateParams{}, fmt.Errorf("failed to parse email address: %w", err)
+ }
+ address.Name = p.ToName
+ valid := ValidOTPEmailCreateParams{
+ Greeting: p.Greeting,
+ LogoClickURL: p.LogoClickURL,
+ LogoImageURL: p.LogoImageURL,
+ ServiceName: p.ServiceName,
+ Subject: p.Subject,
+ SubTitle: p.SubTitle,
+ Title: p.Title,
+ ToEmail: address,
+ }
+ return valid, nil
+}
+
+type ValidOTPEmailCreateParams struct {
+ Greeting string
+ LogoClickURL string
+ LogoImageURL string
+ ServiceName string
+ Subject string
+ SubTitle string
+ Title string
+ ToEmail *mail.Address
+}
+
+type OTPEmailCreateRequest struct {
+ OTPCreateParams OTPCreateParams `json:"otpCreateParams"`
+ OTPEmailCreateParams OTPEmailCreateParams `json:"otpEmailCreateParams"`
+}
+
+func (b OTPEmailCreateRequest) Validate(config Validation) (ValidOTPEmailCreateRequest, error) {
+ otpEmailCreateParams, err := b.OTPEmailCreateParams.Validate(config)
+ if err != nil {
+ return ValidOTPEmailCreateRequest{}, fmt.Errorf("failed to validate email params: %w", err)
+ }
+ otpCreateParams, err := b.OTPCreateParams.Validate(config)
+ if err != nil {
+ return ValidOTPEmailCreateRequest{}, fmt.Errorf("failed to validate link params: %w", err)
+ }
+ valid := ValidOTPEmailCreateRequest{
+ OTPCreateParams: otpCreateParams,
+ OTPEmailCreateParams: otpEmailCreateParams,
+ }
+ return valid, nil
+}
+
+type ValidOTPEmailCreateRequest struct {
+ OTPCreateParams ValidOTPCreateParams
+ OTPEmailCreateParams ValidOTPEmailCreateParams
+}
+
+type OTPEmailCreateResults struct {
+ OTPCreateResults OTPCreateResults `json:"otpCreateResults"`
+}
+
+type OTPEmailCreateResponse struct {
+ OTPEmailCreateResults OTPEmailCreateResults `json:"otpEmailCreateResults"`
+ RequestMetadata RequestMetadata `json:"requestMetadata"`
+}
diff --git a/model/otp_validate.go b/model/otp_validate.go
new file mode 100644
index 0000000..c5c5faa
--- /dev/null
+++ b/model/otp_validate.go
@@ -0,0 +1,55 @@
+package model
+
+import (
+ "fmt"
+
+ "github.com/google/uuid"
+)
+
+type OTPValidateParams struct {
+ ID string `json:"id"`
+ OTP string `json:"otp"`
+}
+
+func (o OTPValidateParams) Validate(_ Validation) (ValidOTPValidateParams, error) {
+ _, err := uuid.Parse(o.ID)
+ if err != nil {
+ return ValidOTPValidateParams{}, fmt.Errorf("currently all OTP IDs must be UUIDs: %w", ErrInvalidModel)
+ }
+ if len(o.OTP) == 0 {
+ return ValidOTPValidateParams{}, fmt.Errorf("%w: OTP cannot be empty", ErrInvalidModel)
+ }
+ valid := ValidOTPValidateParams(o)
+ return valid, nil
+}
+
+type ValidOTPValidateParams struct {
+ ID string
+ OTP string
+}
+
+type OTPValidateRequest struct {
+ OTPValidateParams OTPValidateParams `json:"otpValidateParams"`
+}
+
+func (o OTPValidateRequest) Validate(config Validation) (ValidOTPValidateRequest, error) {
+ validParams, err := o.OTPValidateParams.Validate(config)
+ if err != nil {
+ return ValidOTPValidateRequest{}, fmt.Errorf("failed to validate OTP validate args: %w", err)
+ }
+ valid := ValidOTPValidateRequest{
+ OTPValidateParams: validParams,
+ }
+ return valid, nil
+}
+
+type ValidOTPValidateRequest struct {
+ OTPValidateParams ValidOTPValidateParams
+}
+
+type OTPValidateResults struct{}
+
+type OTPValidateResponse struct {
+ OTPValidateResults OTPValidateResults `json:"otpValidateResults"`
+ RequestMetadata RequestMetadata `json:"requestMetadata"`
+}
diff --git a/model/service_account_create.go b/model/service_account_create.go
index 6a8c945..419e118 100644
--- a/model/service_account_create.go
+++ b/model/service_account_create.go
@@ -4,46 +4,38 @@ import (
"fmt"
)
-// ServiceAccountCreateArgs are the unvalidated arguments to create a service account.
-type ServiceAccountCreateArgs struct{}
+type ServiceAccountCreateParams struct{}
-// Validate implements the Validatable interface.
-func (n ServiceAccountCreateArgs) Validate(_ Validation) (ValidServiceAccountCreateArgs, error) {
- return ValidServiceAccountCreateArgs(n), nil
+func (s ServiceAccountCreateParams) Validate(_ Validation) (ValidServiceAccountCreateParams, error) {
+ return ValidServiceAccountCreateParams(s), nil
}
-// ValidServiceAccountCreateArgs are the validated arguments to create a service account.
-type ValidServiceAccountCreateArgs struct{}
+type ValidServiceAccountCreateParams struct{}
-// ServiceAccountCreateRequest is the unvalidated request to create a service account.
type ServiceAccountCreateRequest struct {
- CreateServiceAccountArgs ServiceAccountCreateArgs `json:"createServiceAccountArgs"`
+ ServiceAccountCreateParams ServiceAccountCreateParams `json:"serviceAccountCreateParams"`
}
-// Validate implements the Validatable interface.
-func (b ServiceAccountCreateRequest) Validate(config Validation) (ValidServiceAccountCreateRequest, error) {
- createServiceAccountArgs, err := b.CreateServiceAccountArgs.Validate(config)
+func (s ServiceAccountCreateRequest) Validate(config Validation) (ValidServiceAccountCreateRequest, error) {
+ serviceAccountCreateParams, err := s.ServiceAccountCreateParams.Validate(config)
if err != nil {
- return ValidServiceAccountCreateRequest{}, fmt.Errorf("failed to validate create service account args: %w", err)
+ return ValidServiceAccountCreateRequest{}, fmt.Errorf("failed to validate service account create args: %w", err)
}
valid := ValidServiceAccountCreateRequest{
- CreateServiceAccountArgs: createServiceAccountArgs,
+ ServiceAccountCreateParams: serviceAccountCreateParams,
}
return valid, nil
}
-// ValidServiceAccountCreateRequest is the validated request to create a service account.
type ValidServiceAccountCreateRequest struct {
- CreateServiceAccountArgs ValidServiceAccountCreateArgs
+ ServiceAccountCreateParams ValidServiceAccountCreateParams
}
-// ServiceAccountCreateResults are the results of creating a service account.
type ServiceAccountCreateResults struct {
ServiceAccount ServiceAccount `json:"serviceAccount"`
}
-// ServiceAccountCreateResponse is the response to creating a service account.
type ServiceAccountCreateResponse struct {
- CreateServiceAccountResults ServiceAccountCreateResults `json:"createServiceAccountResults"`
+ ServiceAccountCreateResults ServiceAccountCreateResults `json:"serviceAccountCreateResults"`
RequestMetadata RequestMetadata `json:"requestMetadata"`
}
diff --git a/model/util.go b/model/util.go
index 39743b0..b2c8ab0 100644
--- a/model/util.go
+++ b/model/util.go
@@ -11,6 +11,7 @@ import (
jt "github.com/MicahParks/jsontype"
"github.com/google/uuid"
+ mld "github.com/MicahParks/magiclinksdev"
"github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
)
@@ -51,7 +52,7 @@ type ServiceAccount struct {
// Validation contains information on how to validate models.
type Validation struct {
LinkLifespanDefault *jt.JSONType[time.Duration] `json:"linkLifespanDefault"`
- LinkLifespanMax *jt.JSONType[time.Duration] `json:"maxLinkLifespan"`
+ LifeSpanSeconds *jt.JSONType[time.Duration] `json:"maxLinkLifespan"`
JWTClaimsMaxBytes uint `json:"maxJWTClaimsBytes"`
JWTLifespanDefault *jt.JSONType[time.Duration] `json:"jwtLifespanDefault"`
JWTLifespanMax *jt.JSONType[time.Duration] `json:"maxJWTLifespan"`
@@ -60,13 +61,12 @@ type Validation struct {
URLMaxLength uint `json:"urlMaxLength"`
}
-// DefaultsAndValidate implements the jsontype.Config interface.
func (v Validation) DefaultsAndValidate() (Validation, error) {
if v.LinkLifespanDefault.Get() == 0 {
v.LinkLifespanDefault = jt.New(time.Hour)
}
- if v.LinkLifespanMax.Get() == 0 {
- v.LinkLifespanMax = jt.New(24 * 30 * time.Hour) // 30 days.
+ if v.LifeSpanSeconds.Get() == 0 {
+ v.LifeSpanSeconds = jt.New(mld.Over250Years)
}
if v.JWTClaimsMaxBytes == 0 {
v.JWTClaimsMaxBytes = 4096
@@ -75,7 +75,7 @@ func (v Validation) DefaultsAndValidate() (Validation, error) {
v.JWTLifespanDefault = jt.New(5 * time.Minute)
}
if v.JWTLifespanMax.Get() == 0 {
- v.JWTLifespanMax = jt.New(24 * 30 * time.Hour) // 30 days.
+ v.JWTLifespanMax = jt.New(mld.Over250Years)
}
if v.ServiceNameMinUTF8 == 0 {
v.ServiceNameMinUTF8 = 5
@@ -90,7 +90,7 @@ func (v Validation) DefaultsAndValidate() (Validation, error) {
}
func httpURL(config Validation, raw string) (*url.URL, error) {
- u, err := url.Parse(raw)
+ u, err := url.ParseRequestURI(raw)
if err != nil {
return nil, fmt.Errorf("failed to parse service URL: %w", err)
}
diff --git a/multi.Dockerfile b/multi.Dockerfile
index e1e3eb1..442be18 100644
--- a/multi.Dockerfile
+++ b/multi.Dockerfile
@@ -1,5 +1,7 @@
FROM golang:1 AS builder
WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o magiclinksdev -trimpath cmd/multi_provider/*.go
diff --git a/network/http.go b/network/http.go
index 0885762..1b323ce 100644
--- a/network/http.go
+++ b/network/http.go
@@ -14,6 +14,7 @@ import (
"github.com/MicahParks/magiclinksdev/model"
"github.com/MicahParks/magiclinksdev/network/middleware"
"github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+ "github.com/MicahParks/magiclinksdev/otp"
"github.com/MicahParks/magiclinksdev/storage"
)
@@ -23,28 +24,31 @@ const (
// Validatable is an interface for validating a model.
type Validatable[T any] interface {
- Validate(s model.Validation) (T, error)
+ Validate(config model.Validation) (T, error)
}
-// HTTPEmailLinkCreate creates an HTTP handler for the HandleEmailLinkCreate method.
-func HTTPEmailLinkCreate(s *handle.Server) http.Handler {
+// HTTPReady creates an HTTP handler that always returns http.StatusOK.
+func HTTPReady(_ *handle.Server) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+}
+
+// HTTPServiceAccountCreate creates an HTTP handler for the HandleServiceAccountCreate method.
+func HTTPServiceAccountCreate(s *handle.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
tx := ctx.Value(ctxkey.Tx).(storage.Tx)
- validated, done := unmarshalRequest[model.EmailLinkCreateRequest, model.ValidEmailLinkCreateRequest](r, s.Config.Validation, w)
+ validated, done := unmarshalRequest[model.ServiceAccountCreateRequest, model.ValidServiceAccountCreateRequest](r, s.Config.Validation, w)
if done {
return
}
- response, err := s.HandleEmailLinkCreate(ctx, validated)
+ response, err := s.HandleServiceAccountCreate(ctx, validated)
if err != nil {
- if errors.Is(err, handle.ErrRegisteredClaimProvided) {
- middleware.WriteErrorBody(ctx, http.StatusBadRequest, responseDontRegisteredClaims, w)
- return
- }
- logger.ErrorContext(ctx, "Failed to create email link.",
+ logger.ErrorContext(ctx, "Failed to create service account.",
mld.LogErr, err,
)
middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
@@ -53,13 +57,18 @@ func HTTPEmailLinkCreate(s *handle.Server) http.Handler {
err = tx.Commit(ctx)
if err != nil {
- logger.ErrorContext(ctx, "Failed to commit transaction for create email link.",
+ logger.ErrorContext(ctx, "Failed to commit transaction for create service account.",
mld.LogErr, err,
)
middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
return
}
+ logger.InfoContext(ctx, "Created new service account.",
+ mld.LogRequestBody, validated,
+ mld.LogResponseBody, response,
+ )
+
writeResponse(ctx, http.StatusCreated, response, w)
})
}
@@ -144,19 +153,19 @@ func HTTPJWTValidate(s *handle.Server) http.Handler {
})
}
-// HTTPLinkCreate creates an HTTP handler for the HandleLinkCreate method.
-func HTTPLinkCreate(s *handle.Server) http.Handler {
+// HTTPMagicLinkCreate creates an HTTP handler for the HandleMagicLinkCreate method.
+func HTTPMagicLinkCreate(s *handle.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
tx := ctx.Value(ctxkey.Tx).(storage.Tx)
- validated, done := unmarshalRequest[model.LinkCreateRequest, model.ValidLinkCreateRequest](r, s.Config.Validation, w)
+ validated, done := unmarshalRequest[model.MagicLinkCreateRequest, model.ValidMagicLinkCreateRequest](r, s.Config.Validation, w)
if done {
return
}
- response, err := s.HandleLinkCreate(ctx, validated)
+ response, err := s.HandleMagicLinkCreate(ctx, validated)
if err != nil {
if errors.Is(err, handle.ErrRegisteredClaimProvided) {
middleware.WriteErrorBody(ctx, http.StatusBadRequest, responseDontRegisteredClaims, w)
@@ -182,28 +191,59 @@ func HTTPLinkCreate(s *handle.Server) http.Handler {
})
}
-// HTTPReady creates an HTTP handler that always returns http.StatusOK.
-func HTTPReady(_ *handle.Server) http.Handler {
+// HTTPMagicLinkEmailCreate creates an HTTP handler for the HandleMagicLinkEmailCreate method.
+func HTTPMagicLinkEmailCreate(s *handle.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
+ ctx := r.Context()
+ logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
+ tx := ctx.Value(ctxkey.Tx).(storage.Tx)
+
+ validated, done := unmarshalRequest[model.MagicLinkEmailCreateRequest, model.ValidMagicLinkEmailCreateRequest](r, s.Config.Validation, w)
+ if done {
+ return
+ }
+
+ response, err := s.HandleMagicLinkEmailCreate(ctx, validated)
+ if err != nil {
+ if errors.Is(err, handle.ErrRegisteredClaimProvided) {
+ middleware.WriteErrorBody(ctx, http.StatusBadRequest, responseDontRegisteredClaims, w)
+ return
+ }
+ logger.ErrorContext(ctx, "Failed to create email link.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
+
+ err = tx.Commit(ctx)
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to commit transaction for create email link.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
+
+ writeResponse(ctx, http.StatusCreated, response, w)
})
}
-// HTTPServiceAccountCreate creates an HTTP handler for the HandleServiceAccountCreate method.
-func HTTPServiceAccountCreate(s *handle.Server) http.Handler {
+// HTTPOTPCreate creates an HTTP handler for the HandleOTPCreate method.
+func HTTPOTPCreate(s *handle.Server) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
tx := ctx.Value(ctxkey.Tx).(storage.Tx)
- validated, done := unmarshalRequest[model.ServiceAccountCreateRequest, model.ValidServiceAccountCreateRequest](r, s.Config.Validation, w)
+ validated, done := unmarshalRequest[model.OTPCreateRequest, model.ValidOTPCreateRequest](r, s.Config.Validation, w)
if done {
return
}
- response, err := s.HandleServiceAccountCreate(ctx, validated)
+ response, err := s.HandleOTPCreate(ctx, validated)
if err != nil {
- logger.ErrorContext(ctx, "Failed to create service account.",
+ logger.ErrorContext(ctx, "Failed to create OTP.",
mld.LogErr, err,
)
middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
@@ -212,17 +252,84 @@ func HTTPServiceAccountCreate(s *handle.Server) http.Handler {
err = tx.Commit(ctx)
if err != nil {
- logger.ErrorContext(ctx, "Failed to commit transaction for create service account.",
+ logger.ErrorContext(ctx, "Failed to commit transaction for create OTP.",
mld.LogErr, err,
)
middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
return
}
- logger.InfoContext(ctx, "Created new service account.",
- mld.LogRequestBody, validated,
- mld.LogResponseBody, response,
- )
+ writeResponse(ctx, http.StatusCreated, response, w)
+ })
+}
+
+// HTTPOTPValidate creates an HTTP handler for the HandleOTPValidate method.
+func HTTPOTPValidate(s *handle.Server) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
+ tx := ctx.Value(ctxkey.Tx).(storage.Tx)
+
+ validated, done := unmarshalRequest[model.OTPValidateRequest, model.ValidOTPValidateRequest](r, s.Config.Validation, w)
+ if done {
+ return
+ }
+
+ response, err := s.HandleOTPValidate(ctx, validated)
+ switch {
+ case errors.Is(err, otp.ErrOTPInvalid):
+ middleware.WriteErrorBody(ctx, http.StatusBadRequest, "Invalid OTP.", w)
+ return
+ case err != nil:
+ logger.ErrorContext(ctx, "Failed to validate OTP.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
+
+ err = tx.Commit(ctx)
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to commit transaction for validate OTP.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
+
+ writeResponse(ctx, http.StatusOK, response, w)
+ })
+}
+
+// HTTPOTPEmailCreate creates an HTTP handler for the HandleOTPEmailCreate method.
+func HTTPOTPEmailCreate(s *handle.Server) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
+ tx := ctx.Value(ctxkey.Tx).(storage.Tx)
+
+ validated, done := unmarshalRequest[model.OTPEmailCreateRequest, model.ValidOTPEmailCreateRequest](r, s.Config.Validation, w)
+ if done {
+ return
+ }
+
+ response, err := s.HandleOTPEmailCreate(ctx, validated)
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to create OTP email.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
+
+ err = tx.Commit(ctx)
+ if err != nil {
+ logger.ErrorContext(ctx, "Failed to commit transaction for create OTP email.",
+ mld.LogErr, err,
+ )
+ middleware.WriteErrorBody(ctx, http.StatusInternalServerError, mld.ResponseInternalServerError, w)
+ return
+ }
writeResponse(ctx, http.StatusCreated, response, w)
})
diff --git a/network/middleware/middleware.go b/network/middleware/middleware.go
index fe03577..dceec97 100644
--- a/network/middleware/middleware.go
+++ b/network/middleware/middleware.go
@@ -75,7 +75,7 @@ func createAuthn(server *handle.Server) Middleware {
return
}
- sa, err := server.Store.ReadSAFromAPIKey(ctx, apiKey)
+ sa, err := server.Store.SAReadFromAPIKey(ctx, apiKey)
if err != nil {
WriteErrorBody(ctx, http.StatusUnauthorized, mld.ResponseUnauthorized, w)
return
diff --git a/network/util.go b/network/util.go
index ddf29fe..af4bb5f 100644
--- a/network/util.go
+++ b/network/util.go
@@ -9,20 +9,26 @@ import (
)
const (
- // PathEmailLinkCreate is the path to the email link creation endpoint.
- PathEmailLinkCreate = "email-link/create"
// PathJWKS is the path to the JWKS endpoint.
PathJWKS = "jwks.json"
- // PathJWTCreate is the path to the JWT creation endpoint.
- PathJWTCreate = "jwt/create"
- // PathJWTValidate is the path to the JWT validation endpoint.
- PathJWTValidate = "jwt/validate"
- // PathLinkCreate is the path to the link creation endpoint.
- PathLinkCreate = "link/create"
// PathReady is the path to the ready endpoint.
PathReady = "ready"
// PathServiceAccountCreate is the path to the service account creation endpoint.
PathServiceAccountCreate = "admin/service-account/create"
+ // PathJWTCreate is the path to the JWT creation endpoint.
+ PathJWTCreate = "jwt/create"
+ // PathJWTValidate is the path to the JWT validation endpoint.
+ PathJWTValidate = "jwt/validate"
+ // PathMagicLinkCreate is the path to the link creation endpoint.
+ PathMagicLinkCreate = "magic-link/create"
+ // PathMagicLinkEmailCreate is the path to the magic link email creation endpoint.
+ PathMagicLinkEmailCreate = "magic-link-email/create"
+ // PathOTPCreate is the path to the OTP creation endpoint.
+ PathOTPCreate = "otp/create"
+ // PathOTPValidate is the path to the OTP validation endpoint.
+ PathOTPValidate = "otp/validate"
+ // PathOTPEmailCreate is the path to the OTP email creation endpoint.
+ PathOTPEmailCreate = "otp-email/create"
)
// CreateHTTPHandlers creates the HTTP handlers for the server.
@@ -30,25 +36,30 @@ func CreateHTTPHandlers(server *handle.Server) (*http.ServeMux, error) {
pathMagicLinkHandler := server.Config.RelativeRedirectURL.Get().EscapedPath()
options := []handle.MiddlewareOptions{
{
- Handler: server.MagicLink.MagicLinkHandler(),
- Path: pathMagicLinkHandler,
+ Handler: server.MagicLink.JWKSHandler(),
+ Path: PathJWKS,
Toggle: handle.MiddlewareToggle{
CommitTx: true,
},
},
{
- Handler: HTTPEmailLinkCreate(server),
- Path: PathEmailLinkCreate,
+ Handler: server.MagicLink.MagicLinkHandler(),
+ Path: pathMagicLinkHandler,
Toggle: handle.MiddlewareToggle{
- Authn: true,
- RateLimit: true,
+ CommitTx: true,
},
},
{
- Handler: server.MagicLink.JWKSHandler(),
- Path: PathJWKS,
+ Handler: HTTPReady(server),
+ Path: PathReady,
+ Toggle: handle.MiddlewareToggle{},
+ },
+ {
+ Handler: HTTPServiceAccountCreate(server),
+ Path: PathServiceAccountCreate,
Toggle: handle.MiddlewareToggle{
- CommitTx: true,
+ Admin: true,
+ Authn: true,
},
},
{
@@ -68,24 +79,43 @@ func CreateHTTPHandlers(server *handle.Server) (*http.ServeMux, error) {
},
},
{
- Handler: HTTPLinkCreate(server),
- Path: PathLinkCreate,
+ Handler: HTTPMagicLinkCreate(server),
+ Path: PathMagicLinkCreate,
Toggle: handle.MiddlewareToggle{
Authn: true,
RateLimit: true,
},
},
{
- Handler: HTTPReady(server),
- Path: PathReady,
- Toggle: handle.MiddlewareToggle{},
+ Handler: HTTPMagicLinkEmailCreate(server),
+ Path: PathMagicLinkEmailCreate,
+ Toggle: handle.MiddlewareToggle{
+ Authn: true,
+ RateLimit: true,
+ },
},
{
- Handler: HTTPServiceAccountCreate(server),
- Path: PathServiceAccountCreate,
+ Handler: HTTPOTPCreate(server),
+ Path: PathOTPCreate,
Toggle: handle.MiddlewareToggle{
- Admin: true,
- Authn: true,
+ Authn: true,
+ RateLimit: true,
+ },
+ },
+ {
+ Handler: HTTPOTPValidate(server),
+ Path: PathOTPValidate,
+ Toggle: handle.MiddlewareToggle{
+ Authn: true,
+ RateLimit: true,
+ },
+ },
+ {
+ Handler: HTTPOTPEmailCreate(server),
+ Path: PathOTPEmailCreate,
+ Toggle: handle.MiddlewareToggle{
+ Authn: true,
+ RateLimit: true,
},
},
}
diff --git a/nop.Dockerfile b/nop.Dockerfile
index fd9e05a..ccb29fd 100644
--- a/nop.Dockerfile
+++ b/nop.Dockerfile
@@ -1,5 +1,7 @@
FROM golang:1 AS builder
WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o magiclinksdev -trimpath cmd/nop_provider/*.go
diff --git a/openapi.yaml b/openapi.yaml
index 627131d..98d78f1 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -1,14 +1,14 @@
openapi: 3.0.1
info:
title: magiclinks.dev
- description: "The API specification for the magiclinksdev project. \n\n The default\
- \ JWK Set relative URL path is `/api/v1/jwks.json`. \n\n The documentation site\
+ description: "The v2 API specification for the magiclinksdev project. \n\n The default\
+ \ JWK Set relative URL path is `/api/v2/jwks.json`. \n\n The documentation site\
\ is at https://docs.magiclinks.dev \n This is an Apache License 2.0 project:\
\ https://github.com/MicahParks/magiclinksdev \n The optional SaaS platform's\
\ landing page is: https://magiclinks.dev "
- version: 1.0.0
+ version: 2.0.0
servers:
- - url: https://magiclinks.dev/api/v1
+ - url: https://magiclinks.dev/api/v2
security:
- apiKey: []
tags:
@@ -17,12 +17,11 @@ tags:
paths:
/ready:
get:
- summary: Check if the service is running and ready to accept requests.
- description: Check if the service is running and ready to accept requests.
+ summary: Check if the service is ready to accept requests.
operationId: ready
responses:
"200":
- description: The service is running and ready to accept requests.
+ description: The service is ready to accept requests.
content: {}
default:
description: The service is not ready to accept requests.
@@ -31,11 +30,9 @@ paths:
post:
tags:
- admin
- summary: Create a new service account
- description: Create a new service account with the parameters.
+ summary: Create a new service account.
operationId: serviceAccountCreate
requestBody:
- description: Service account object that needs to be added.
content:
application/json:
schema:
@@ -43,13 +40,13 @@ paths:
required: true
responses:
"201":
- description: successful operation
+ description: The service account has been created.
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceAccountCreateResponse'
default:
- description: An error occurred.
+ description: An unexpected error occurred.
content:
application/json:
schema:
@@ -57,12 +54,9 @@ paths:
x-codegen-request-body-name: body
/jwt/create:
post:
- summary: "Create a JWT, typically for a JWT refresh."
- description: Create a JWT with the parameters. The intended use case is to refresh
- an authentic and valid JWT.
+ summary: "Create a JWT, typically after OTP verification or a JWT refresh."
operationId: jwtCreate
requestBody:
- description: The JWT payload.
content:
application/json:
schema:
@@ -70,13 +64,13 @@ paths:
required: true
responses:
"201":
- description: The JWT was created successfully.
+ description: The JWT was created.
content:
application/json:
schema:
$ref: '#/components/schemas/JWTCreateResponse'
default:
- description: An error occurred.
+ description: An unexpected error occurred.
content:
application/json:
schema:
@@ -84,15 +78,9 @@ paths:
x-codegen-request-body-name: body
/jwt/validate:
post:
- summary: Validate a JWT.
- description: "Validate a JWT and return the payload. Ideally the client would\
- \ cache a copy of the JWK Set and validate JWTs locally. This endpoint is\
- \ for use cases where the native language does not have an adequate JWK Set\
- \ client. Consider deploying an instance of the JWK Set Client Proxy (JCP)\
- \ if you need this in production: https://github.com/MicahParks/jcp"
+ summary: Verify and validate a JWT.
operationId: jwtValidate
requestBody:
- description: The JWT to validate.
content:
application/json:
schema:
@@ -100,43 +88,66 @@ paths:
required: true
responses:
"200":
- description: The JWT was signed by an active key in the JWK Set.
+ description: The JWT is verified and validated.
content:
application/json:
schema:
$ref: '#/components/schemas/JWTValidateResponse'
"422":
- description: The given JWT was invalid.
+ description: The JWT failed verification and validation.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
default:
- description: An error occurred.
+ description: An unexpected error occurred.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-codegen-request-body-name: body
- /link/create:
+ /magic-link/create:
post:
summary: Create a magic link.
- description: "Create a magic link that, when clicked, will create a JWT with\
- \ the given claims and include that JWT in the URL query key of a redirect."
- operationId: linkCreate
+ operationId: magicLinkCreate
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MagicLinkCreateRequest'
+ required: true
+ responses:
+ "201":
+ description: The magic link was created.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MagicLinkCreateResponse'
+ default:
+ description: An unexpected error occurred.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ x-codegen-request-body-name: body
+ /magic-link-email/create:
+ post:
+ summary: Create a magic link and send it via email.
+ operationId: magicLinkEmailCreate
requestBody:
content:
application/json:
schema:
- $ref: '#/components/schemas/LinkCreateRequest'
+ $ref: '#/components/schemas/MagicLinkEmailCreateRequest'
required: true
responses:
"201":
- description: The magic link was created successfully.
+ description: The magic link has been created and the email request has been
+ accepted by the provider.
content:
application/json:
schema:
- $ref: '#/components/schemas/LinkCreateResponse'
+ $ref: '#/components/schemas/MagicLinkEmailCreateResponse'
default:
description: An unexpected error occurred.
content:
@@ -144,24 +155,78 @@ paths:
schema:
$ref: '#/components/schemas/Error'
x-codegen-request-body-name: body
- /email-link/create:
+ /otp/create:
post:
- summary: Send a magic link via email.
- description: Create and send a magic link via email.
- operationId: emailLinkCreate
+ summary: Create a One-Time Password (OTP).
+ operationId: otpCreate
requestBody:
content:
application/json:
schema:
- $ref: '#/components/schemas/EmailLinkCreateRequest'
+ $ref: '#/components/schemas/OTPCreateRequest'
required: true
responses:
"201":
- description: The request has been accepted by the email provider.
+ description: The OTP was created.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OTPCreateResponse'
+ default:
+ description: An unexpected error occurred.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ x-codegen-request-body-name: body
+ /otp/validate:
+ post:
+ summary: Verify and validate a One-Time Password (OTP) given its ID.
+ operationId: otpValidate
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OTPValidateRequest'
+ required: true
+ responses:
+ "200":
+ description: The OTP is verified and valid for the given ID.
content:
application/json:
schema:
- $ref: '#/components/schemas/EmailLinkCreateResponse'
+ $ref: '#/components/schemas/OTPValidateResponse'
+ "400":
+ description: The OTP failed verification or validation for the given ID.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ default:
+ description: An unexpected error occurred.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ x-codegen-request-body-name: body
+ /otp-email/create:
+ post:
+ summary: Create a One-Time Password (OTP) and sent it via email.
+ operationId: otpEmailCreate
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OTPEmailCreateRequest'
+ required: true
+ responses:
+ "201":
+ description: The OTP has been created and the email request has been accepted
+ by the provider.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OTPEmailCreateResponse'
default:
description: An unexpected error occurred.
content:
@@ -195,49 +260,42 @@ components:
properties:
uuid:
type: string
- description: The UUID of the service account.
format: uuid
apiKey:
type: string
- description: The API key of the service account.
aud:
type: string
- description: The audience of the service account.
admin:
type: boolean
- description: Whether the service account is an admin.
- JWTCreateRequest:
- required:
- - jwtCreateArgs
- type: object
- properties:
- jwtCreateArgs:
- $ref: '#/components/schemas/JWTCreateArgs'
- description: The request body for the /jwt/create endpoint.
- JWTCreateArgs:
+ JWTCreateParams:
type: object
properties:
- jwtAlg:
+ alg:
type: string
description: The algorithm to use when signing the JWT. It defaults depends
on the server's configuration. The default server configuration is "EdDSA".
The default server options are "EdDSA" and "RS256".
- jwtClaims:
+ claims:
type: object
- properties:
- claims:
- type: object
- properties: {}
- description: Any valid JSON object. Do not provide any JSON attributes
- mentioned in RFC 7519 section 4.1 as this will cause an error. These
- are JWT "Registered Claim Names".
- description: The JWT claims used to create the signed JWT.
- jwtLifespanSeconds:
+ properties: {}
+ description: Any valid JSON object. Do not provide any JSON attributes mentioned
+ in RFC 7519 section 4.1 as this will cause an error. These are JWT "Registered
+ Claim Names".
+ lifespanSeconds:
type: integer
description: "The lifespan of the JWT in seconds. The JWT's lifespan starts\
- \ after the magic link has been visited and the JWT is signed. It defaults\
- \ to 5 minutes. The minimum value is 5 and the maximum value is 2,592,000."
+ \ after creation. For magic links, after the magic link has been visited.\
+ \ It defaults to 5 minutes. The minimum value is 5 seconds and the maximum\
+ \ value is 7905600000 seconds, which is a bit over 250 years."
description: Parameters used to create a JWT.
+ JWTCreateRequest:
+ required:
+ - jwtCreateParams
+ type: object
+ properties:
+ jwtCreateParams:
+ $ref: '#/components/schemas/JWTCreateParams'
+ description: The request body for the /jwt/create endpoint.
JWTCreateResults:
type: object
properties:
@@ -253,21 +311,21 @@ components:
requestMetadata:
$ref: '#/components/schemas/RequestMetadata'
description: The response body for the /jwt/create endpoint.
- JWTValidateRequest:
- required:
- - jwtValidateArgs
- type: object
- properties:
- jwtValidateArgs:
- $ref: '#/components/schemas/JWTValidateArgs'
- description: The request body for the /jwt/validate endpoint.
- JWTValidateArgs:
+ JWTValidateParams:
type: object
properties:
jwt:
type: string
description: The JWT to validate.
description: Parameters used to validate a JWT.
+ JWTValidateRequest:
+ required:
+ - jwtValidateParams
+ type: object
+ properties:
+ jwtValidateParams:
+ $ref: '#/components/schemas/JWTValidateParams'
+ description: The request body for the /jwt/validate endpoint.
JWTValidateResults:
type: object
properties:
@@ -286,56 +344,54 @@ components:
requestMetadata:
$ref: '#/components/schemas/RequestMetadata'
description: The response body for the /jwt/validate endpoint.
- LinkCreateArgs:
+ MagicLinkCreateParams:
+ required:
+ - redirectURL
type: object
properties:
- jwtCreateArgs:
- $ref: '#/components/schemas/JWTCreateArgs'
- linkLifespan:
+ jwtCreateParams:
+ $ref: '#/components/schemas/JWTCreateParams'
+ lifespanSeconds:
type: integer
- description: "The number of seconds the link should be active for after\
- \ the request has been processed. It defaults to 1 hour. The minimum value\
- \ is 5 and the maximum value is 2,592,000."
+ description: "The lifespan of the magic link in seconds. The magic link's\
+ \ lifespan starts after it has been created. It defaults to 1 hour. The\
+ \ minimum value is 5 seconds and the maximum value is 7905600000 seconds,\
+ \ which is a bit over 250 years."
redirectQueryKey:
type: string
description: "The URL query key in the redirectURL to contain the signed\
\ JWT when the magic link is used. By default, \"jwt\" is used."
- redirectUrl:
+ redirectURL:
type: string
- description: The URL to redirect to with the signed JWT when the link is
- used.
- description: Parameters to create any magic link.
- LinkCreateRequest:
+ description: The URL to redirect to with the signed JWT when the magic link
+ is used.
+ description: Parameters to create a magic link.
+ MagicLinkCreateRequest:
required:
- - linkArgs
+ - magicLinkCreateParams
type: object
properties:
- linkArgs:
- $ref: '#/components/schemas/LinkCreateArgs'
- description: The request body for the /link/create endpoint.
- LinkCreateResults:
+ magicLinkCreateParams:
+ $ref: '#/components/schemas/MagicLinkCreateParams'
+ MagicLinkCreateResults:
type: object
properties:
magicLink:
type: string
- description: "The magic link that, when visited, will sign a JWT with the\
- \ given information and pass it along in a redirect to the given URL.\
- \ The link can only be used once. This link should as if it were the signed\
- \ JWT."
+ description: "The URL that will act as a magic link. When this URL is visited,\
+ \ a new JWT will be created. A redirect wil be performed with this new\
+ \ JWT in the redirect URL's query parameter."
secret:
type: string
- description: "The secret embedded in the magic link. It can only be used\
- \ once, so using it will automatically expire the link."
- description: The results for creating a magic link.
- LinkCreateResponse:
+ description: The secret embedded in the magic link.
+ MagicLinkCreateResponse:
type: object
properties:
- linkCreateResults:
- $ref: '#/components/schemas/LinkCreateResults'
+ magicLinkCreateResults:
+ $ref: '#/components/schemas/MagicLinkCreateResults'
requestMetadata:
$ref: '#/components/schemas/RequestMetadata'
- description: The response body for the /link/create endpoint.
- EmailLinkCreateArgs:
+ MagicLinkEmailCreateParams:
required:
- serviceName
- subject
@@ -389,42 +445,184 @@ components:
type: string
description: The name of the recipient.
example: Jane Doe
- description: Parameters to create an email magic link.
- EmailLinkCreateRequest:
+ description: Parameters to create a magic link email.
+ MagicLinkEmailCreateRequest:
required:
- - emailArgs
- - linkArgs
+ - magicLinkCreateParams
+ - magicLinkEmailCreateParams
type: object
properties:
- emailArgs:
- $ref: '#/components/schemas/EmailLinkCreateArgs'
- linkArgs:
- $ref: '#/components/schemas/LinkCreateArgs'
- description: The request body for the /email-link/create endpoint.
- EmailLinkCreateResults:
+ magicLinkCreateParams:
+ $ref: '#/components/schemas/MagicLinkCreateParams'
+ magicLinkEmailCreateParams:
+ $ref: '#/components/schemas/MagicLinkEmailCreateParams'
+ MagicLinkEmailCreateResults:
type: object
properties:
- linkCreateResults:
- $ref: '#/components/schemas/LinkCreateResults'
- description: The results for creating an email magic link.
- EmailLinkCreateResponse:
+ magicLinkCreateResults:
+ $ref: '#/components/schemas/MagicLinkCreateResults'
+ description: The results for creating a magic link email.
+ MagicLinkEmailCreateResponse:
type: object
properties:
- emailLinkCreateResults:
- $ref: '#/components/schemas/EmailLinkCreateResults'
+ magicLinkEmailCreateResults:
+ $ref: '#/components/schemas/MagicLinkEmailCreateResults'
requestMetadata:
$ref: '#/components/schemas/RequestMetadata'
- description: The response body from the /email-link/create endpoint.
- ServiceAccountCreateArgs:
+ OTPCreateParams:
+ type: object
+ properties:
+ charSetAlphaLower:
+ type: boolean
+ description: Include a chance to use lowercase letters in the OTP.
+ charSetAlphaUpper:
+ type: boolean
+ description: Include a chance to use uppercase letters in the OTP.
+ charSetNumeric:
+ type: boolean
+ description: Include a chance to use numbers in the OTP.
+ length:
+ type: integer
+ description: The length of the OTP. It defaults to 6. The minimum value
+ is 1 and the maximum value is 12.
+ lifespanSeconds:
+ type: integer
+ description: "The lifespan of the OTP in seconds. The OTP's lifespan starts\
+ \ after it has been created. It defaults to 1 hour. The minimum value\
+ \ is 5 seconds and the maximum value is 7905600000 seconds, which is a\
+ \ bit over 250 years."
+ description: Parameters to create a One-Time Password (OTP).
+ OTPCreateRequest:
+ required:
+ - otpCreateParams
+ type: object
+ properties:
+ otpCreateParams:
+ $ref: '#/components/schemas/OTPCreateParams'
+ OTPCreateResults:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The ID of the OTP.
+ otp:
+ type: string
+ description: The One-Time Password.
+ description: The results for creating a One-Time Password (OTP).
+ OTPCreateResponse:
+ type: object
+ properties:
+ otpCreateResults:
+ $ref: '#/components/schemas/OTPCreateResults'
+ requestMetadata:
+ $ref: '#/components/schemas/RequestMetadata'
+ OTPEmailCreateParams:
+ required:
+ - serviceName
+ - subject
+ - title
+ - toEmail
+ type: object
+ properties:
+ greeting:
+ type: string
+ description: The smaller text above the title.
+ example: "Hello Jane Doe,"
+ logoClickURL:
+ type: string
+ description: The URL to redirect to when the logo is clicked.
+ example: https://example.com
+ logoImageURL:
+ type: string
+ description: The URL to the logo image to display in the email.
+ example: https://example.com/logo.png
+ serviceName:
+ type: string
+ description: The name of your service. This is used in invisible email metadata.
+ example: example.com
+ subject:
+ type: string
+ description: The subject of the email. It must be between 5 and 100 characters
+ inclusive. Make sure to include the name of your application.
+ example: Login to example.com
+ subTitle:
+ type: string
+ description: "The smaller text, right above the magic link button."
+ example: Login using the button below.
+ title:
+ type: string
+ description: "The larger text, right above the subtitle. It must be between\
+ \ 5 and 256 characters inclusive. Make sure to include the name of your\
+ \ application."
+ example: Login to example.com with a magic link
+ toEmail:
+ type: string
+ description: The email address to send the magic link to.
+ format: email
+ example: jane.doe@example.com
+ toName:
+ type: string
+ description: The name of the recipient.
+ example: Jane Doe
+ description: Parameters to create a One-Time Password (OTP) email.
+ OTPEmailCreateRequest:
+ required:
+ - otpCreateParams
+ - otpEmailCreateParams
+ type: object
+ properties:
+ otpCreateParams:
+ $ref: '#/components/schemas/OTPCreateParams'
+ otpEmailCreateParams:
+ $ref: '#/components/schemas/OTPEmailCreateParams'
+ OTPEmailCreateResults:
+ type: object
+ properties:
+ otpCreateResults:
+ $ref: '#/components/schemas/OTPCreateResults'
+ description: The results for creating a One-Time Password (OTP) email.
+ OTPEmailCreateResponse:
+ type: object
+ properties:
+ otpEmailCreateResults:
+ $ref: '#/components/schemas/OTPEmailCreateResults'
+ requestMetadata:
+ $ref: '#/components/schemas/RequestMetadata'
+ OTPValidateParams:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The ID of the OTP to validate.
+ otp:
+ type: string
+ description: The user provided One-Time Password to validate.
+ description: Parameters to validate a One-Time Password (OTP).
+ OTPValidateRequest:
+ type: object
+ properties:
+ otpValidateParams:
+ $ref: '#/components/schemas/OTPValidateParams'
+ OTPValidateResults:
+ type: object
+ OTPValidateResponse:
+ required:
+ - otpValidateResults
+ - requestMetadata
+ type: object
+ properties:
+ otpValidateResults:
+ $ref: '#/components/schemas/OTPValidateResults'
+ requestMetadata:
+ $ref: '#/components/schemas/RequestMetadata'
+ ServiceAccountCreateParams:
type: object
description: Parameters to create a service account.
ServiceAccountCreateRequest:
- required:
- - createServiceAccountArgs
type: object
properties:
- createServiceAccountArgs:
- $ref: '#/components/schemas/ServiceAccountCreateArgs'
+ serviceAccountCreateParams:
+ $ref: '#/components/schemas/ServiceAccountCreateParams'
description: The request body for the /admin/service-account/create endpoint.
ServiceAccountCreateResults:
type: object
@@ -435,7 +633,7 @@ components:
ServiceAccountCreateResponse:
type: object
properties:
- createServiceAccountResults:
+ serviceAccountCreateResults:
$ref: '#/components/schemas/ServiceAccountCreateResults'
requestMetadata:
$ref: '#/components/schemas/RequestMetadata'
diff --git a/otp/generate.go b/otp/generate.go
new file mode 100644
index 0000000..29e832f
--- /dev/null
+++ b/otp/generate.go
@@ -0,0 +1,44 @@
+package otp
+
+import (
+ "crypto/rand"
+ "fmt"
+ "math/big"
+ "strings"
+
+ mld "github.com/MicahParks/magiclinksdev"
+)
+
+var (
+ alphaLower = []rune("abcdefghijklmnopqrstuvwxyz")
+ alphaUpper = []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+ numeric = []rune("0123456789")
+)
+
+func Generate(args CreateParams) (string, error) {
+ charSet := make([]rune, 0)
+ if args.CharSetAlphaLower {
+ charSet = append(charSet, alphaLower...)
+ }
+ if args.CharSetAlphaUpper {
+ charSet = append(charSet, alphaUpper...)
+ }
+ if args.CharSetNumeric {
+ charSet = append(charSet, numeric...)
+ }
+ if len(charSet) == 0 {
+ return "", fmt.Errorf("must include at least one character set: %w", mld.ErrParams)
+ }
+ o := strings.Builder{}
+ for range args.Length {
+ i, err := rand.Int(rand.Reader, big.NewInt(int64(len(charSet))))
+ if err != nil {
+ return "", fmt.Errorf("failed to read random number for OTP: %w", err)
+ }
+ _, err = o.WriteRune(charSet[i.Int64()])
+ if err != nil {
+ return "", fmt.Errorf("failed to write rune to OTP string builder: %w", err)
+ }
+ }
+ return o.String(), nil
+}
diff --git a/otp/generate_test.go b/otp/generate_test.go
new file mode 100644
index 0000000..a00978a
--- /dev/null
+++ b/otp/generate_test.go
@@ -0,0 +1,116 @@
+package otp
+
+import (
+ "errors"
+ "testing"
+
+ mld "github.com/MicahParks/magiclinksdev"
+)
+
+func TestGenerate(t *testing.T) {
+ tc := []struct {
+ name string
+ params CreateParams
+ expectedErr error
+ }{
+ {
+ name: "Empty",
+ params: CreateParams{},
+ expectedErr: mld.ErrParams,
+ },
+ {
+ name: "LengthZero",
+ params: CreateParams{
+ CharSetNumeric: true,
+ },
+ expectedErr: nil,
+ },
+ {
+ name: "AlphaLower",
+ params: CreateParams{
+ CharSetAlphaLower: true,
+ CharSetAlphaUpper: false,
+ CharSetNumeric: false,
+ Length: mld.DefaultOTPLength,
+ },
+ },
+ {
+ name: "AlphaUpper",
+ params: CreateParams{
+ CharSetAlphaLower: false,
+ CharSetAlphaUpper: true,
+ CharSetNumeric: false,
+ Length: mld.DefaultOTPLength,
+ },
+ },
+ {
+ name: "Numeric",
+ params: CreateParams{
+ CharSetAlphaLower: false,
+ CharSetAlphaUpper: false,
+ CharSetNumeric: true,
+ Length: mld.DefaultOTPLength,
+ },
+ },
+ {
+ name: "AlphaNumeric",
+ params: CreateParams{
+ CharSetAlphaLower: true,
+ CharSetAlphaUpper: true,
+ CharSetNumeric: true,
+ Length: mld.DefaultOTPLength,
+ },
+ },
+ {
+ name: "ShortLength",
+ params: CreateParams{
+ CharSetAlphaLower: true,
+ CharSetAlphaUpper: true,
+ CharSetNumeric: true,
+ Length: 1,
+ },
+ },
+ {
+ name: "LongLength",
+ params: CreateParams{
+ CharSetAlphaLower: true,
+ CharSetAlphaUpper: true,
+ CharSetNumeric: true,
+ Length: 12,
+ },
+ },
+ }
+ for _, tt := range tc {
+ t.Run(tt.name, func(t *testing.T) {
+ o, err := Generate(tt.params)
+ if !errors.Is(err, tt.expectedErr) {
+ t.Errorf("expected %v, got %v", tt.expectedErr, err)
+ }
+ expectedSet := make([]rune, 0)
+ if tt.params.CharSetAlphaLower {
+ expectedSet = append(expectedSet, alphaLower...)
+ }
+ if tt.params.CharSetAlphaUpper {
+ expectedSet = append(expectedSet, alphaUpper...)
+ }
+ if tt.params.CharSetNumeric {
+ expectedSet = append(expectedSet, numeric...)
+ }
+ if uint(len(o)) != tt.params.Length {
+ t.Fatalf("expected length %d, got %d", tt.params.Length, len(o))
+ }
+ for _, r := range o {
+ found := false
+ for _, e := range expectedSet {
+ if r == e {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("rune %c not found in expected set", r)
+ }
+ }
+ })
+ }
+}
diff --git a/otp/storage.go b/otp/storage.go
new file mode 100644
index 0000000..bb02c8d
--- /dev/null
+++ b/otp/storage.go
@@ -0,0 +1,28 @@
+package otp
+
+import (
+ "context"
+ "errors"
+ "time"
+)
+
+var ErrOTPInvalid = errors.New("OTP invalid")
+
+type CreateParams struct {
+ CharSetAlphaLower bool
+ CharSetAlphaUpper bool
+ CharSetNumeric bool
+ Expires time.Time
+ Length uint
+}
+
+type CreateResult struct {
+ CreateParams CreateParams
+ ID string
+ OTP string
+}
+
+type Storage interface {
+ OTPCreate(ctx context.Context, params CreateParams) (CreateResult, error)
+ OTPValidate(ctx context.Context, id, o string) error
+}
diff --git a/otp_test.go b/otp_test.go
new file mode 100644
index 0000000..eb8566f
--- /dev/null
+++ b/otp_test.go
@@ -0,0 +1,125 @@
+package magiclinksdev_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ mld "github.com/MicahParks/magiclinksdev"
+ "github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/network"
+ "github.com/MicahParks/magiclinksdev/network/middleware"
+)
+
+func TestOTP(t *testing.T) {
+
+ for _, tc := range []struct {
+ name string
+ reqBody model.OTPCreateRequest
+ }{
+ {
+ name: "NumericDefault",
+ reqBody: model.OTPCreateRequest{
+ OTPCreateParams: model.OTPCreateParams{
+ CharSetAlphaLower: false,
+ CharSetAlphaUpper: false,
+ CharSetNumeric: true,
+ Length: 0,
+ LifespanSeconds: 0,
+ },
+ },
+ },
+ {
+ name: "AllLong",
+ reqBody: model.OTPCreateRequest{
+ OTPCreateParams: model.OTPCreateParams{
+ CharSetAlphaLower: true,
+ CharSetAlphaUpper: true,
+ CharSetNumeric: true,
+ Length: 12,
+ LifespanSeconds: 0,
+ },
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ marshaled, err := json.Marshal(tc.reqBody)
+ if err != nil {
+ t.Fatalf("Failed to marshal request body: %v", err)
+ }
+
+ recorder := httptest.NewRecorder()
+ u, err := assets.conf.Server.BaseURL.Get().Parse(network.PathOTPCreate)
+ if err != nil {
+ t.Fatalf("Failed to parse URL: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, u.Path, bytes.NewReader(marshaled))
+ req.Header.Set(mld.HeaderContentType, mld.ContentTypeJSON)
+ req.Header.Set(middleware.APIKeyHeader, assets.sa.APIKey.String())
+ assets.mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != http.StatusCreated {
+ t.Fatalf("Received non-200 status code: %d\n%s", recorder.Code, recorder.Body.String())
+ }
+ if recorder.Header().Get(mld.HeaderContentType) != mld.ContentTypeJSON {
+ t.Fatalf("Received non-JSON content type: %s", recorder.Header().Get(mld.HeaderContentType))
+ }
+
+ var optCreateResponse model.OTPCreateResponse
+ err = json.Unmarshal(recorder.Body.Bytes(), &optCreateResponse)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal response body: %v", err)
+ }
+
+ recorder = httptest.NewRecorder()
+ u, err = assets.conf.Server.BaseURL.Get().Parse(network.PathOTPValidate)
+ if err != nil {
+ t.Fatalf("Failed to parse URL: %v", err)
+ }
+ body := model.OTPValidateRequest{
+ OTPValidateParams: model.OTPValidateParams{
+ ID: optCreateResponse.OTPCreateResults.ID,
+ OTP: optCreateResponse.OTPCreateResults.OTP,
+ },
+ }
+ marshaled, err = json.Marshal(body)
+ if err != nil {
+ t.Fatalf("Failed to marshal request body: %v", err)
+ }
+ req = httptest.NewRequest(http.MethodPost, u.String(), bytes.NewReader(marshaled))
+ req.Header.Set(mld.HeaderContentType, mld.ContentTypeJSON)
+ req.Header.Set(middleware.APIKeyHeader, assets.sa.APIKey.String())
+ assets.mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("Expected status code %d, got %d", http.StatusOK, recorder.Code)
+ }
+
+ recorder = httptest.NewRecorder()
+ u, err = assets.conf.Server.BaseURL.Get().Parse(network.PathOTPValidate)
+ if err != nil {
+ t.Fatalf("Failed to parse URL: %v", err)
+ }
+ body = model.OTPValidateRequest{
+ OTPValidateParams: model.OTPValidateParams{
+ ID: optCreateResponse.OTPCreateResults.ID,
+ OTP: optCreateResponse.OTPCreateResults.OTP,
+ },
+ }
+ marshaled, err = json.Marshal(body)
+ if err != nil {
+ t.Fatalf("Failed to marshal request body: %v", err)
+ }
+ req = httptest.NewRequest(http.MethodPost, u.String(), bytes.NewReader(marshaled))
+ req.Header.Set(mld.HeaderContentType, mld.ContentTypeJSON)
+ req.Header.Set(middleware.APIKeyHeader, assets.sa.APIKey.String())
+ assets.mux.ServeHTTP(recorder, req)
+
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("Expected status code %d, got %d", http.StatusBadRequest, recorder.Code)
+ }
+ })
+ }
+}
diff --git a/sendgrid.Dockerfile b/sendgrid.Dockerfile
index 0fe7c2b..821af25 100644
--- a/sendgrid.Dockerfile
+++ b/sendgrid.Dockerfile
@@ -1,5 +1,7 @@
FROM golang:1 AS builder
WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o magiclinksdev -trimpath cmd/sendgrid_provider/*.go
diff --git a/ses.Dockerfile b/ses.Dockerfile
index 2d14f53..f2f5a4d 100644
--- a/ses.Dockerfile
+++ b/ses.Dockerfile
@@ -1,5 +1,7 @@
FROM golang:1 AS builder
WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags "-s -w" -o magiclinksdev -trimpath cmd/ses_provider/*.go
diff --git a/setup/keys.go b/setup/keys.go
index 7acc5dd..1add7d4 100644
--- a/setup/keys.go
+++ b/setup/keys.go
@@ -39,7 +39,7 @@ func CreateKeysIfNotExists(ctx context.Context, store storage.Storage) (keys []j
if !(haveEdDSA && haveRS256) {
return nil, false, fmt.Errorf("%w: expected to have an EdDSA and an RS256 key", ErrJWKSet)
}
- jwk, err := store.ReadDefaultSigningKey(ctx)
+ jwk, err := store.SigningKeyDefaultRead(ctx)
if err != nil {
return nil, false, fmt.Errorf("failed to read default signing key: %w", err)
}
@@ -75,7 +75,7 @@ func CreateKeysIfNotExists(ctx context.Context, store storage.Storage) (keys []j
return nil, false, fmt.Errorf("failed to write EdDSA JWK: %w", err)
}
- err = store.UpdateDefaultSigningKey(ctx, jwk.Marshal().KID)
+ err = store.SigningKeyDefaultUpdate(ctx, jwk.Marshal().KID)
if err != nil {
return nil, false, fmt.Errorf("failed to update default signing key: %w", err)
}
diff --git a/setup/setup.go b/setup/setup.go
index 5ad8483..a2640a3 100644
--- a/setup/setup.go
+++ b/setup/setup.go
@@ -324,7 +324,7 @@ func CreateServer(ctx context.Context, conf config.Config, options ServerOptions
magicLinkConfig := magiclink.Config{
ErrorHandler: MagicLinkErrorHandler(options.MagicLinkErrorHandler),
- JWKS: magiclink.JWKSArgs{
+ JWKS: magiclink.JWKSParams{
CacheRefresh: time.Second,
Store: interfaces.Store,
},
@@ -357,15 +357,15 @@ func CreateServer(ctx context.Context, conf config.Config, options ServerOptions
logger.InfoContext(ctx, "Ignoring default JWK Set check.")
}
- for _, adminConfig := range conf.AdminConfig {
- valid, err := adminConfig.Validate(conf.Validation)
+ for _, adminCreateParams := range conf.AdminCreateParams {
+ valid, err := adminCreateParams.Validate(conf.Validation)
if err != nil {
return nil, fmt.Errorf("failed to validate admin config: %w", err)
}
- _, err = interfaces.Store.ReadSA(setupCtx, valid.UUID)
+ _, err = interfaces.Store.SARead(setupCtx, valid.UUID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
- err = interfaces.Store.CreateAdminSA(setupCtx, valid)
+ err = interfaces.Store.SAAdminCreate(setupCtx, valid)
if err != nil {
return nil, fmt.Errorf("failed to setup admin: %w", err)
}
@@ -406,7 +406,7 @@ func CreateServer(ctx context.Context, conf config.Config, options ServerOptions
// MagicLinkErrorHandler is a wrapper for magiclink.ErrorHandlerFunc.
func MagicLinkErrorHandler(h magiclink.ErrorHandler) magiclink.ErrorHandler {
- return magiclink.ErrorHandlerFunc(func(args magiclink.ErrorHandlerArgs) {
+ return magiclink.ErrorHandlerFunc(func(args magiclink.ErrorHandlerParams) {
ctx := args.Request.Context()
logger := ctx.Value(ctxkey.Logger).(*slog.Logger)
logger.ErrorContext(ctx, "Failed to handle magic link.",
@@ -438,9 +438,14 @@ type nopProvider struct {
logger *slog.Logger
}
-// Send implements email.Provider.
-func (n nopProvider) Send(ctx context.Context, e email.Email) error {
- n.logger.DebugContext(ctx, "Sending email.",
+func (n nopProvider) SendMagicLink(ctx context.Context, e email.Email) error {
+ n.logger.DebugContext(ctx, "Sending magic link email.",
+ "email", e,
+ )
+ return nil
+}
+func (n nopProvider) SendOTP(ctx context.Context, e email.Email) error {
+ n.logger.DebugContext(ctx, "Sending OTP email.",
"email", e,
)
return nil
diff --git a/storage/interface.go b/storage/interface.go
index 85f5a84..8148806 100644
--- a/storage/interface.go
+++ b/storage/interface.go
@@ -9,6 +9,7 @@ import (
"github.com/MicahParks/magiclinksdev/magiclink"
"github.com/MicahParks/magiclinksdev/model"
+ "github.com/MicahParks/magiclinksdev/otp"
)
var (
@@ -24,16 +25,17 @@ type Storage interface {
Close(ctx context.Context) error
TestingTruncate(ctx context.Context) error
- CreateAdminSA(ctx context.Context, args model.ValidAdminCreateArgs) error
- CreateSA(ctx context.Context, args model.ValidServiceAccountCreateArgs) (model.ServiceAccount, error)
- ReadSA(ctx context.Context, u uuid.UUID) (model.ServiceAccount, error)
- ReadSAFromAPIKey(ctx context.Context, apiKey uuid.UUID) (model.ServiceAccount, error)
- ReadSigningKey(ctx context.Context, options ReadSigningKeyOptions) (jwk jwkset.JWK, err error)
- ReadDefaultSigningKey(ctx context.Context) (jwk jwkset.JWK, err error)
- UpdateDefaultSigningKey(ctx context.Context, keyID string) error
+ SAAdminCreate(ctx context.Context, args model.ValidAdminCreateParams) error
+ SACreate(ctx context.Context, args model.ValidServiceAccountCreateParams) (model.ServiceAccount, error)
+ SARead(ctx context.Context, u uuid.UUID) (model.ServiceAccount, error)
+ SAReadFromAPIKey(ctx context.Context, apiKey uuid.UUID) (model.ServiceAccount, error)
+ SigningKeyRead(ctx context.Context, options ReadSigningKeyOptions) (jwk jwkset.JWK, err error)
+ SigningKeyDefaultRead(ctx context.Context) (jwk jwkset.JWK, err error)
+ SigningKeyDefaultUpdate(ctx context.Context, keyID string) error
jwkset.Storage
magiclink.Storage
+ otp.Storage
}
// Tx is the interface for a transaction.
diff --git a/storage/migrate.go b/storage/migrate.go
index 1cd0cb6..2b17e3a 100644
--- a/storage/migrate.go
+++ b/storage/migrate.go
@@ -177,6 +177,7 @@ func NewMigrator(pool *pgxpool.Pool, options MigratorOptions) (Migrator, error)
migrations := []migration{
algMigration{},
+ otpMigration{},
}
m := migrator{
diff --git a/storage/postgres.go b/storage/postgres.go
index efaab6b..b5c77cc 100644
--- a/storage/postgres.go
+++ b/storage/postgres.go
@@ -23,8 +23,8 @@ import (
"github.com/MicahParks/magiclinksdev"
"github.com/MicahParks/magiclinksdev/magiclink"
"github.com/MicahParks/magiclinksdev/model"
-
"github.com/MicahParks/magiclinksdev/network/middleware/ctxkey"
+ "github.com/MicahParks/magiclinksdev/otp"
)
const (
@@ -140,7 +140,7 @@ func (p postgres) TestingTruncate(ctx context.Context) error {
//language=sql
const query = `
-TRUNCATE TABLE mld.jwk, mld.link, mld.service_account
+TRUNCATE TABLE mld.jwk, mld.link, mld.otp, mld.service_account
`
_, err := tx.Exec(ctx, query)
if err != nil {
@@ -150,7 +150,7 @@ TRUNCATE TABLE mld.jwk, mld.link, mld.service_account
return nil
}
-func (p postgres) CreateAdminSA(ctx context.Context, args model.ValidAdminCreateArgs) error {
+func (p postgres) SAAdminCreate(ctx context.Context, args model.ValidAdminCreateParams) error {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
_, err := tx.Exec(ctx, createServiceAccountQuery, args.UUID, args.APIKey, args.Aud, true)
@@ -160,7 +160,7 @@ func (p postgres) CreateAdminSA(ctx context.Context, args model.ValidAdminCreate
return nil
}
-func (p postgres) CreateSA(ctx context.Context, _ model.ValidServiceAccountCreateArgs) (model.ServiceAccount, error) {
+func (p postgres) SACreate(ctx context.Context, _ model.ValidServiceAccountCreateParams) (model.ServiceAccount, error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
apiKey, err := uuid.NewRandom()
@@ -190,7 +190,7 @@ func (p postgres) CreateSA(ctx context.Context, _ model.ValidServiceAccountCreat
return sa, nil
}
-func (p postgres) ReadSA(ctx context.Context, u uuid.UUID) (model.ServiceAccount, error) {
+func (p postgres) SARead(ctx context.Context, u uuid.UUID) (model.ServiceAccount, error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
//language=sql
@@ -212,7 +212,7 @@ WHERE uuid = $1
return sa, nil
}
-func (p postgres) ReadSAFromAPIKey(ctx context.Context, apiKey uuid.UUID) (model.ServiceAccount, error) {
+func (p postgres) SAReadFromAPIKey(ctx context.Context, apiKey uuid.UUID) (model.ServiceAccount, error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
//language=sql
@@ -234,7 +234,7 @@ WHERE api_key = $1
return sa, nil
}
-func (p postgres) ReadSigningKey(ctx context.Context, options ReadSigningKeyOptions) (jwk jwkset.JWK, err error) {
+func (p postgres) SigningKeyRead(ctx context.Context, options ReadSigningKeyOptions) (jwk jwkset.JWK, err error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
//language=sql
@@ -272,7 +272,7 @@ ORDER BY created DESC
return jwk, nil
}
-func (p postgres) ReadDefaultSigningKey(ctx context.Context) (jwk jwkset.JWK, err error) {
+func (p postgres) SigningKeyDefaultRead(ctx context.Context) (jwk jwkset.JWK, err error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
//language=sql
@@ -297,7 +297,7 @@ WHERE signing_default = TRUE
return jwk, nil
}
-func (p postgres) UpdateDefaultSigningKey(ctx context.Context, keyID string) error {
+func (p postgres) SigningKeyDefaultUpdate(ctx context.Context, keyID string) error {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
//language=sql
@@ -318,7 +318,7 @@ WHERE key_id = $1
Magic link storage.
*/
-func (p postgres) CreateLink(ctx context.Context, args magiclink.CreateArgs) (secret string, err error) {
+func (p postgres) MagicLinkCreate(ctx context.Context, args magiclink.CreateParams) (secret string, err error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
sa := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount)
@@ -347,9 +347,9 @@ VALUES ($2, $3, $4, $5, $6, $7, $8, (SELECT id FROM sa))
return s.String(), nil
}
-func (p postgres) ReadLink(ctx context.Context, secret string) (magiclink.ReadResponse, error) {
+func (p postgres) MagicLinkRead(ctx context.Context, secret string) (magiclink.ReadResult, error) {
tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
- var response magiclink.ReadResponse
+ var response magiclink.ReadResult
u, err := uuid.Parse(secret)
if err != nil {
@@ -366,7 +366,7 @@ WHERE older.id = updated.id
RETURNING updated.expires, updated.jwt_claims, updated.jwt_key_id, updated.jwt_signing_method, updated.redirect_query_key, updated.redirect_url, older.visited
`
claims := make([]byte, 0)
- var args magiclink.CreateArgs
+ var args magiclink.CreateParams
var visited *time.Time
var redirectURL string
err = tx.QueryRow(ctx, query, u.String()).Scan(&args.Expires, &claims, &args.JWTKeyID, &args.JWTSigningMethod, &args.RedirectQueryKey, &redirectURL, &visited)
@@ -395,11 +395,73 @@ RETURNING updated.expires, updated.jwt_claims, updated.jwt_key_id, updated.jwt_s
return response, fmt.Errorf("failed to parse redirect URL from Postgres: %w", err)
}
- response.CreateArgs = args
+ response.CreateParams = args
response.Visited = visited
return response, nil
}
+/*
+OTP Storage
+*/
+
+func (p postgres) OTPCreate(ctx context.Context, params otp.CreateParams) (otp.CreateResult, error) {
+ tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
+ sa := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount)
+
+ o, err := otp.Generate(params)
+ if err != nil {
+ return otp.CreateResult{}, fmt.Errorf("failed to generate OTP: %w", err)
+ }
+
+ publicID := uuid.New()
+
+ //language=sql
+ const query = `
+WITH sa AS (SELECT id FROM mld.service_account WHERE uuid = $1)
+INSERT INTO mld.otp (sa_id, expires, id_public, otp) VALUES ((SELECT id FROM sa), $2, $3, $4)
+`
+ _, err = tx.Exec(ctx, query, sa.UUID, params.Expires, publicID, o)
+ if err != nil {
+ return otp.CreateResult{}, fmt.Errorf("failed to write OTP to Postgres: %w", err)
+ }
+
+ results := otp.CreateResult{
+ CreateParams: params,
+ ID: publicID.String(),
+ OTP: o,
+ }
+
+ return results, nil
+}
+func (p postgres) OTPValidate(ctx context.Context, id, o string) error {
+ tx := ctx.Value(ctxkey.Tx).(*Transaction).Tx
+
+ u, err := uuid.Parse(id)
+ if err != nil {
+ return fmt.Errorf("failed to parse UUID: %w", otp.ErrOTPInvalid)
+ }
+
+ //language=sql
+ const query = `
+UPDATE mld.otp updated
+SET used = CURRENT_TIMESTAMP
+WHERE updated.id_public = $1
+ AND updated.otp = $2
+ AND updated.expires > CURRENT_TIMESTAMP
+ AND updated.used IS NULL
+`
+ result, err := tx.Exec(ctx, query, u, o)
+ if err != nil {
+ return fmt.Errorf("failed to query database: %w", err)
+ }
+
+ if result.RowsAffected() < 1 {
+ return fmt.Errorf("no rows were updated: %w", otp.ErrOTPInvalid)
+ }
+
+ return nil
+}
+
/*
JWK Set Storage
*/
@@ -448,6 +510,7 @@ func (p postgres) KeyReadAll(ctx context.Context) ([]jwkset.JWK, error) {
const query = `
SELECT assets, signing_default
FROM mld.jwk
+ORDER BY signing_default DESC
`
rows, err := tx.Query(ctx, query)
if err != nil {
diff --git a/storage/setup.go b/storage/setup.go
index 0337dd4..e466b1e 100644
--- a/storage/setup.go
+++ b/storage/setup.go
@@ -14,7 +14,7 @@ import (
)
const (
- databaseVersion = "v0.1.0"
+ databaseVersion = "v0.2.0"
)
var (
diff --git a/storage/startup.sql b/storage/startup.sql
index 49575eb..8bca39c 100644
--- a/storage/startup.sql
+++ b/storage/startup.sql
@@ -14,7 +14,7 @@ INSERT INTO mld.setup (setup)
VALUES ('{
"plaintextClaims": false,
"plaintextJWK": false,
- "semver": "v0.1.0"
+ "semver": "v0.2.0"
}');
CREATE TABLE mld.service_account
@@ -64,3 +64,19 @@ CREATE INDEX ON mld.link (secret);
CREATE INDEX ON mld.link (sa_id);
CREATE INDEX ON mld.link (visited);
CREATE INDEX ON mld.link (created);
+
+CREATE TABLE mld.otp
+(
+ id BIGSERIAL PRIMARY KEY,
+ sa_id BIGINT NOT NULL REFERENCES mld.service_account (id),
+ expires TIMESTAMP WITH TIME ZONE NOT NULL,
+ id_public UUID NOT NULL UNIQUE,
+ otp TEXT NOT NULL,
+ used TIMESTAMP WITH TIME ZONE,
+ created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX ON mld.otp (sa_id);
+CREATE INDEX ON mld.otp (expires);
+CREATE INDEX ON mld.otp (id_public);
+CREATE INDEX ON mld.otp (used);
+CREATE INDEX ON mld.otp (created);
diff --git a/storage/types.go b/storage/types.go
index 9a312e1..900951f 100644
--- a/storage/types.go
+++ b/storage/types.go
@@ -1,6 +1,6 @@
package storage
-// ReadSigningKeyOptions are the options for the ReadSigningKey method.
+// ReadSigningKeyOptions are the options for the SigningKeyRead method.
type ReadSigningKeyOptions struct {
JWTAlg string
}
diff --git a/storage/v0.2.0_otp.go b/storage/v0.2.0_otp.go
new file mode 100644
index 0000000..f948594
--- /dev/null
+++ b/storage/v0.2.0_otp.go
@@ -0,0 +1,75 @@
+package storage
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// otpMigration is the migration from database version v0.1.0 to v0.2.0. This is the first database migration.
+type otpMigration struct{}
+
+func (a otpMigration) metadata() metadata {
+ return metadata{
+ Description: `This migrates the database from version v0.1.0 to v0.2.0. This is the second database migration. It adds the "mld.otp" table for One-Time Password support.`,
+ Filename: "v0.2.0_otp.go",
+ SemVer: "v0.2.0",
+ }
+}
+
+func (a otpMigration) migrate(ctx context.Context, setup Setup, tx pgx.Tx, options migrationOptions) (applied bool, err error) {
+ needed, err := migrationNeeded(a.metadata().SemVer, setup.SemVer)
+ if err != nil {
+ return false, fmt.Errorf("failed to determine if migration is needed: %w", err)
+ }
+ if !needed {
+ return false, nil
+ }
+
+ //language=sql
+ query := `
+CREATE TABLE mld.otp
+(
+ id BIGSERIAL PRIMARY KEY,
+ sa_id BIGINT NOT NULL REFERENCES mld.service_account (id),
+ expires TIMESTAMP WITH TIME ZONE NOT NULL,
+ id_public UUID NOT NULL UNIQUE,
+ otp TEXT NOT NULL,
+ used TIMESTAMP WITH TIME ZONE,
+ created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
+)
+`
+ _, err = tx.Exec(ctx, query)
+ if err != nil {
+ return false, fmt.Errorf("failed to create new table for %q query: %w", a.metadata().Filename, err)
+ }
+ options.Logger.DebugContext(ctx, `Added "mld.otp" table.`)
+
+ //language=sql
+ query = `
+CREATE INDEX ON mld.jwk (alg)
+`
+ _, err = tx.Exec(ctx, query)
+ if err != nil {
+ return false, fmt.Errorf("failed to create index for %q query: %w", a.metadata().Filename, err)
+ }
+ options.Logger.DebugContext(ctx, `Created index on "alg" column of "mld.jwk" table.`)
+
+ indexes := []string{
+ "CREATE INDEX ON mld.otp (sa_id)",
+ "CREATE INDEX ON mld.otp (expires)",
+ "CREATE INDEX ON mld.otp (id_public)",
+ "CREATE INDEX ON mld.otp (used)",
+ "CREATE INDEX ON mld.otp (created)",
+ }
+ for _, index := range indexes {
+ _, err = tx.Exec(ctx, index)
+ if err != nil {
+ return false, fmt.Errorf("failed to create index for %q: %q, %w", a.metadata().Filename, index, err)
+ }
+ options.Logger.DebugContext(ctx, fmt.Sprintf(`Created index on "mld.otp" table: %q.`, index))
+ }
+
+ return true, nil
+}
diff --git a/swagger.yaml b/swagger.yaml
index 4c8da66..5111de0 100644
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -2,9 +2,9 @@ swagger: "2.0" # https://stackoverflow.com/a/59749691/14797322
info:
title: "magiclinks.dev"
- description: "The API specification for the magiclinksdev project.
+ description: "The v2 API specification for the magiclinksdev project.
\n\n
- The default JWK Set relative URL path is `/api/v1/jwks.json`.
+ The default JWK Set relative URL path is `/api/v2/jwks.json`.
\n\n
The documentation site is at https://docs.magiclinks.dev
\n
@@ -12,10 +12,10 @@ info:
\n
The optional SaaS platform's landing page is: https://magiclinks.dev
"
- version: "1.0.0"
+ version: "2.0.0"
host: "magiclinks.dev"
-basePath: "/api/v1"
+basePath: "/api/v2"
schemes:
- "https"
@@ -28,12 +28,11 @@ produces:
paths:
/ready:
get:
- summary: "Check if the service is running and ready to accept requests."
- description: "Check if the service is running and ready to accept requests."
+ summary: "Check if the service is ready to accept requests."
operationId: "ready"
responses:
200:
- description: "The service is running and ready to accept requests."
+ description: "The service is ready to accept requests."
default:
description: "The service is not ready to accept requests."
@@ -41,115 +40,167 @@ paths:
post:
tags:
- "admin"
- summary: "Create a new service account"
- description: "Create a new service account with the parameters."
+ summary: "Create a new service account."
operationId: "serviceAccountCreate"
parameters:
- in: "body"
name: "body"
- description: "Service account object that needs to be added."
required: true
schema:
$ref: "#/definitions/ServiceAccountCreateRequest"
responses:
201:
- description: "successful operation"
+ description: "The service account has been created."
schema:
$ref: "#/definitions/ServiceAccountCreateResponse"
default:
- description: "An error occurred."
+ description: "An unexpected error occurred."
schema:
$ref: "#/definitions/Error"
/jwt/create:
post:
- summary: "Create a JWT, typically for a JWT refresh."
- description: "Create a JWT with the parameters. The intended use case is to refresh an authentic and valid JWT."
+ summary: "Create a JWT, typically after OTP verification or a JWT refresh."
operationId: "jwtCreate"
parameters:
- name: "body"
in: "body"
- description: "The JWT payload."
required: true
schema:
$ref: "#/definitions/JWTCreateRequest"
responses:
201:
- description: "The JWT was created successfully."
+ description: "The JWT was created."
schema:
$ref: "#/definitions/JWTCreateResponse"
default:
- description: "An error occurred."
+ description: "An unexpected error occurred."
schema:
$ref: "#/definitions/Error"
/jwt/validate:
post:
- summary: "Validate a JWT."
- description: "Validate a JWT and return the payload. Ideally the client would cache a copy of the JWK Set and
- validate JWTs locally. This endpoint is for use cases where the native language does not have an adequate JWK Set
- client. Consider deploying an instance of the JWK Set Client Proxy (JCP) if you need this in production:
- https://github.com/MicahParks/jcp"
+ summary: "Verify and validate a JWT."
operationId: "jwtValidate"
parameters:
- in: "body"
name: "body"
- description: "The JWT to validate."
required: true
schema:
$ref: "#/definitions/JWTValidateRequest"
responses:
200:
- description: "The JWT was signed by an active key in the JWK Set."
+ description: "The JWT is verified and validated."
schema:
$ref: "#/definitions/JWTValidateResponse"
422:
- description: "The given JWT was invalid."
+ description: "The JWT failed verification and validation."
schema:
$ref: "#/definitions/Error"
default:
- description: "An error occurred."
+ description: "An unexpected error occurred."
schema:
$ref: "#/definitions/Error"
- /link/create:
+ /magic-link/create:
post:
summary: "Create a magic link."
- description: "Create a magic link that, when clicked, will create a JWT with the given claims and include that JWT
- in the URL query key of a redirect."
- operationId: "linkCreate"
+ operationId: "magicLinkCreate"
+ parameters:
+ - in: "body"
+ name: "body"
+ required: true
+ schema:
+ $ref: "#/definitions/MagicLinkCreateRequest"
+ responses:
+ 201:
+ description: "The magic link was created."
+ schema:
+ $ref: "#/definitions/MagicLinkCreateResponse"
+ default:
+ description: "An unexpected error occurred."
+ schema:
+ $ref: "#/definitions/Error"
+
+ /magic-link-email/create:
+ post:
+ summary: "Create a magic link and send it via email."
+ operationId: "magicLinkEmailCreate"
+ parameters:
+ - in: "body"
+ name: "body"
+ required: true
+ schema:
+ $ref: "#/definitions/MagicLinkEmailCreateRequest"
+ responses:
+ 201:
+ description: "The magic link has been created and the email request has been accepted by the provider."
+ schema:
+ $ref: "#/definitions/MagicLinkEmailCreateResponse"
+ default:
+ description: "An unexpected error occurred."
+ schema:
+ $ref: "#/definitions/Error"
+
+ /otp/create:
+ post:
+ summary: "Create a One-Time Password (OTP)."
+ operationId: "otpCreate"
parameters:
- in: "body"
name: "body"
required: true
schema:
- $ref: "#/definitions/LinkCreateRequest"
+ $ref: "#/definitions/OTPCreateRequest"
responses:
201:
- description: "The magic link was created successfully."
+ description: "The OTP was created."
+ schema:
+ $ref: "#/definitions/OTPCreateResponse"
+ default:
+ description: "An unexpected error occurred."
+ schema:
+ $ref: "#/definitions/Error"
+
+ /otp/validate:
+ post:
+ summary: "Verify and validate a One-Time Password (OTP) given its ID."
+ operationId: "otpValidate"
+ parameters:
+ - in: "body"
+ name: "body"
+ required: true
+ schema:
+ $ref: "#/definitions/OTPValidateRequest"
+ responses:
+ 200:
+ description: "The OTP is verified and valid for the given ID."
+ schema:
+ $ref: "#/definitions/OTPValidateResponse"
+ 400:
+ description: "The OTP failed verification or validation for the given ID."
schema:
- $ref: "#/definitions/LinkCreateResponse"
+ $ref: "#/definitions/Error"
default:
description: "An unexpected error occurred."
schema:
$ref: "#/definitions/Error"
- /email-link/create:
+ /otp-email/create:
post:
- summary: "Send a magic link via email."
- description: "Create and send a magic link via email."
- operationId: "emailLinkCreate"
+ summary: "Create a One-Time Password (OTP) and sent it via email."
+ operationId: "otpEmailCreate"
parameters:
- in: "body"
name: "body"
required: true
schema:
- $ref: "#/definitions/EmailLinkCreateRequest"
+ $ref: "#/definitions/OTPEmailCreateRequest"
responses:
201:
- description: "The request has been accepted by the email provider."
+ description: "The OTP has been created and the email request has been accepted by the provider."
schema:
- $ref: "#/definitions/EmailLinkCreateResponse"
+ $ref: "#/definitions/OTPEmailCreateResponse"
default:
description: "An unexpected error occurred."
schema:
@@ -182,53 +233,45 @@ definitions:
properties:
uuid:
type: "string"
- description: "The UUID of the service account."
format: "uuid"
apiKey:
type: "string"
- description: "The API key of the service account."
aud:
type: "string"
- description: "The audience of the service account."
admin:
type: "boolean"
- description: "Whether the service account is an admin."
- JWTCreateRequest:
- description: "The request body for the /jwt/create endpoint."
- type: "object"
- properties:
- jwtCreateArgs:
- $ref: "#/definitions/JWTCreateArgs"
- required:
- - "jwtCreateArgs"
-
- JWTCreateArgs:
+ JWTCreateParams:
description: "Parameters used to create a JWT."
type: "object"
properties:
- jwtAlg:
+ alg:
description: "The algorithm to use when signing the JWT. It defaults depends on the server's configuration. The
default server configuration is \"EdDSA\". The default server options are \"EdDSA\" and \"RS256\"."
type: "string"
- jwtClaims:
- description: "The JWT claims used to create the signed JWT."
+ claims:
+ description: 'Any valid JSON object. Do not provide any JSON attributes mentioned in RFC 7519 section 4.1 as
+ this will cause an error. These are JWT "Registered Claim Names".'
type: "object"
- properties:
- claims:
- description: 'Any valid JSON object. Do not provide any JSON attributes mentioned in RFC 7519 section 4.1 as
- this will cause an error. These are JWT "Registered Claim Names".'
- type: "object"
- externalDocs:
- description: "RFC 7519 section 4.1"
- url: "https://tools.ietf.org/html/rfc7519#section-4.1"
- jwtLifespanSeconds:
- description: "The lifespan of the JWT in seconds. The JWT's lifespan starts after the magic link has been
- visited and the JWT is signed. It defaults to 5 minutes. The minimum value is 5 and the maximum value is
- 2,592,000."
+ externalDocs:
+ description: "RFC 7519 section 4.1"
+ url: "https://tools.ietf.org/html/rfc7519#section-4.1"
+ lifespanSeconds:
+ description: "The lifespan of the JWT in seconds. The JWT's lifespan starts after creation. For magic links,
+ after the magic link has been visited. It defaults to 5 minutes. The minimum value is 5 seconds and the maximum
+ value is 7905600000 seconds, which is a bit over 250 years."
default: 300
type: "integer"
+ JWTCreateRequest:
+ description: "The request body for the /jwt/create endpoint."
+ type: "object"
+ properties:
+ jwtCreateParams:
+ $ref: "#/definitions/JWTCreateParams"
+ required:
+ - "jwtCreateParams"
+
JWTCreateResults:
description: "The results for creating a JWT."
type: "object"
@@ -246,16 +289,7 @@ definitions:
requestMetadata:
$ref: "#/definitions/RequestMetadata"
- JWTValidateRequest:
- description: "The request body for the /jwt/validate endpoint."
- type: "object"
- properties:
- jwtValidateArgs:
- $ref: "#/definitions/JWTValidateArgs"
- required:
- - "jwtValidateArgs"
-
- JWTValidateArgs:
+ JWTValidateParams:
description: "Parameters used to validate a JWT."
type: "object"
properties:
@@ -263,6 +297,15 @@ definitions:
description: "The JWT to validate."
type: "string"
+ JWTValidateRequest:
+ description: "The request body for the /jwt/validate endpoint."
+ type: "object"
+ properties:
+ jwtValidateParams:
+ $ref: "#/definitions/JWTValidateParams"
+ required:
+ - "jwtValidateParams"
+
JWTValidateResults:
description: "The results for validateing a JWT."
type: "object"
@@ -284,61 +327,57 @@ definitions:
requestMetadata:
$ref: "#/definitions/RequestMetadata"
- LinkCreateArgs:
- description: "Parameters to create any magic link."
+ MagicLinkCreateParams:
+ description: "Parameters to create a magic link."
type: "object"
properties:
- jwtCreateArgs:
- description: "The parameters used to create the JWT to pass upon redirection."
- $ref: "#/definitions/JWTCreateArgs"
- linkLifespan:
- description: "The number of seconds the link should be active for after the request has been processed. It
- defaults to 1 hour. The minimum value is 5 and the maximum value is 2,592,000."
+ jwtCreateParams:
+ $ref: "#/definitions/JWTCreateParams"
+ lifespanSeconds:
+ description: "The lifespan of the magic link in seconds. The magic link's lifespan starts after it has been
+ created. It defaults to 1 hour. The minimum value is 5 seconds and the maximum value is 7905600000 seconds,
+ which is a bit over 250 years."
type: "integer"
default: 3600
redirectQueryKey:
description: 'The URL query key in the redirectURL to contain the signed JWT when the magic link is used. By
default, "jwt" is used.'
type: "string"
- redirectUrl:
- description: "The URL to redirect to with the signed JWT when the link is used."
+ redirectURL:
+ description: "The URL to redirect to with the signed JWT when the magic link is used."
type: "string"
required:
- "redirectURL"
- LinkCreateRequest:
- description: "The request body for the /link/create endpoint."
+ MagicLinkCreateRequest:
type: "object"
properties:
- linkArgs:
- $ref: "#/definitions/LinkCreateArgs"
+ magicLinkCreateParams:
+ $ref: "#/definitions/MagicLinkCreateParams"
required:
- - "linkArgs"
+ - "magicLinkCreateParams"
- LinkCreateResults:
- description: "The results for creating a magic link."
+ MagicLinkCreateResults:
type: "object"
properties:
magicLink:
- description: "The magic link that, when visited, will sign a JWT with the given information and pass it along in
- a redirect to the given URL. The link can only be used once. This link should as if it were the signed JWT."
+ description: "The URL that will act as a magic link. When this URL is visited, a new JWT will be created. A
+ redirect wil be performed with this new JWT in the redirect URL's query parameter."
type: "string"
secret:
- description: "The secret embedded in the magic link. It can only be used once, so using it will automatically
- expire the link."
+ description: "The secret embedded in the magic link."
type: "string"
- LinkCreateResponse:
- description: "The response body for the /link/create endpoint."
+ MagicLinkCreateResponse:
type: "object"
properties:
- linkCreateResults:
- $ref: "#/definitions/LinkCreateResults"
+ magicLinkCreateResults:
+ $ref: "#/definitions/MagicLinkCreateResults"
requestMetadata:
$ref: "#/definitions/RequestMetadata"
- EmailLinkCreateArgs:
- description: "Parameters to create an email magic link."
+ MagicLinkEmailCreateParams:
+ description: "Parameters to create a magic link email."
type: "object"
properties:
buttonText:
@@ -391,35 +430,190 @@ definitions:
- "toEmail"
- "serviceName"
- EmailLinkCreateRequest:
- description: "The request body for the /email-link/create endpoint."
+ MagicLinkEmailCreateRequest:
+ type: "object"
+ properties:
+ magicLinkCreateParams:
+ $ref: "#/definitions/MagicLinkCreateParams"
+ magicLinkEmailCreateParams:
+ $ref: "#/definitions/MagicLinkEmailCreateParams"
+ required:
+ - "magicLinkCreateParams"
+ - "magicLinkEmailCreateParams"
+
+ MagicLinkEmailCreateResults:
+ description: "The results for creating a magic link email."
+ type: "object"
+ properties:
+ magicLinkCreateResults:
+ $ref: "#/definitions/MagicLinkCreateResults"
+
+ MagicLinkEmailCreateResponse:
+ type: "object"
+ properties:
+ magicLinkEmailCreateResults:
+ $ref: "#/definitions/MagicLinkEmailCreateResults"
+ requestMetadata:
+ $ref: "#/definitions/RequestMetadata"
+
+ OTPCreateParams:
+ description: "Parameters to create a One-Time Password (OTP)."
+ type: "object"
+ properties:
+ charSetAlphaLower:
+ description: "Include a chance to use lowercase letters in the OTP."
+ type: "boolean"
+ charSetAlphaUpper:
+ description: "Include a chance to use uppercase letters in the OTP."
+ type: "boolean"
+ charSetNumeric:
+ description: "Include a chance to use numbers in the OTP."
+ type: "boolean"
+ length:
+ description: "The length of the OTP. It defaults to 6. The minimum value is 1 and the maximum value is 12."
+ default: 6
+ type: "integer"
+ lifespanSeconds:
+ description: "The lifespan of the OTP in seconds. The OTP's lifespan starts after it has been created. It
+ defaults to 1 hour. The minimum value is 5 seconds and the maximum value is 7905600000 seconds, which is a bit
+ over 250 years."
+ default: 3600
+ type: "integer"
+
+ OTPCreateRequest:
+ type: "object"
+ properties:
+ otpCreateParams:
+ $ref: "#/definitions/OTPCreateParams"
+ required:
+ - "otpCreateParams"
+
+ OTPCreateResults:
+ description: "The results for creating a One-Time Password (OTP)."
+ type: "object"
+ properties:
+ id:
+ description: "The ID of the OTP."
+ type: "string"
+ otp:
+ description: "The One-Time Password."
+ type: "string"
+
+ OTPCreateResponse:
+ type: "object"
+ properties:
+ otpCreateResults:
+ $ref: "#/definitions/OTPCreateResults"
+ requestMetadata:
+ $ref: "#/definitions/RequestMetadata"
+
+ OTPEmailCreateParams:
+ description: "Parameters to create a One-Time Password (OTP) email."
+ type: "object"
+ properties:
+ greeting:
+ description: 'The smaller text above the title.'
+ type: "string"
+ example: "Hello Jane Doe,"
+ logoClickURL:
+ description: "The URL to redirect to when the logo is clicked."
+ type: "string"
+ example: "https://example.com"
+ logoImageURL:
+ description: "The URL to the logo image to display in the email."
+ type: "string"
+ example: "https://example.com/logo.png"
+ serviceName:
+ description: "The name of your service. This is used in invisible email metadata."
+ type: "string"
+ example: "example.com"
+ subject:
+ description: 'The subject of the email. It must be between 5 and 100 characters inclusive. Make sure to include
+ the name of your application.'
+ type: "string"
+ example: "Login to example.com"
+ subTitle:
+ description: "The smaller text, right above the magic link button."
+ type: "string"
+ example: "Login using the button below."
+ title:
+ description: 'The larger text, right above the subtitle. It must be between 5 and 256 characters inclusive.
+ Make sure to include the name of your application.'
+ type: "string"
+ example: "Login to example.com with a magic link"
+ toEmail:
+ description: "The email address to send the magic link to."
+ type: "string"
+ format: "email"
+ example: "jane.doe@example.com"
+ toName:
+ description: "The name of the recipient."
+ type: "string"
+ example: "Jane Doe"
+ required:
+ - "subject"
+ - "title"
+ - "toEmail"
+ - "serviceName"
+
+ OTPEmailCreateRequest:
type: "object"
properties:
- emailArgs:
- $ref: "#/definitions/EmailLinkCreateArgs"
- linkArgs:
- $ref: "#/definitions/LinkCreateArgs"
+ otpCreateParams:
+ $ref: "#/definitions/OTPCreateParams"
+ otpEmailCreateParams:
+ $ref: "#/definitions/OTPEmailCreateParams"
required:
- - "emailArgs"
- - "linkArgs"
+ - "otpCreateParams"
+ - "otpEmailCreateParams"
+
+ OTPEmailCreateResults:
+ description: "The results for creating a One-Time Password (OTP) email."
+ type: "object"
+ properties:
+ otpCreateResults:
+ $ref: "#/definitions/OTPCreateResults"
+
+ OTPEmailCreateResponse:
+ type: "object"
+ properties:
+ otpEmailCreateResults:
+ $ref: "#/definitions/OTPEmailCreateResults"
+ requestMetadata:
+ $ref: "#/definitions/RequestMetadata"
- EmailLinkCreateResults:
- description: "The results for creating an email magic link."
+ OTPValidateParams:
+ description: "Parameters to validate a One-Time Password (OTP)."
type: "object"
properties:
- linkCreateResults:
- $ref: "#/definitions/LinkCreateResults"
+ id:
+ description: "The ID of the OTP to validate."
+ type: "string"
+ otp:
+ description: "The user provided One-Time Password to validate."
+ type: "string"
- EmailLinkCreateResponse:
- description: "The response body from the /email-link/create endpoint."
+ OTPValidateRequest:
type: "object"
properties:
- emailLinkCreateResults:
- $ref: "#/definitions/EmailLinkCreateResults"
+ otpValidateParams:
+ $ref: "#/definitions/OTPValidateParams"
+
+ OTPValidateResults:
+ type: "object"
+
+ OTPValidateResponse:
+ type: "object"
+ properties:
+ otpValidateResults:
+ $ref: "#/definitions/OTPValidateResults"
requestMetadata:
$ref: "#/definitions/RequestMetadata"
+ required:
+ - "otpValidateResults"
+ - "requestMetadata"
- ServiceAccountCreateArgs:
+ ServiceAccountCreateParams:
description: "Parameters to create a service account."
type: "object"
@@ -427,24 +621,21 @@ definitions:
description: "The request body for the /admin/service-account/create endpoint."
type: "object"
properties:
- createServiceAccountArgs:
- $ref: "#/definitions/ServiceAccountCreateArgs"
- required:
- - "createServiceAccountArgs"
+ serviceAccountCreateParams:
+ $ref: "#/definitions/ServiceAccountCreateParams"
ServiceAccountCreateResults:
description: "The results for creating a service account."
type: "object"
properties:
serviceAccount:
- description: "The service account that was created."
$ref: "#/definitions/ServiceAccount"
ServiceAccountCreateResponse:
description: "The response body for the /admin/service-account/create endpoint."
type: "object"
properties:
- createServiceAccountResults:
+ serviceAccountCreateResults:
$ref: "#/definitions/ServiceAccountCreateResults"
requestMetadata:
$ref: "#/definitions/RequestMetadata"