Skip to content

Commit 0b08c17

Browse files
committed
Add Magic Link Authentication executor
Implement Magic Link authentication support for the flow engine: * Magic link token generation and email delivery * Token verification with expiration handling * Email template for magic link sign-in * MagicLinkAuthnService in registry for executor integration Refactor magic link URL parameters to use 'queryParams' instead of 'flowId' Update clientSecret values and example in API documentation Update README to reflect changes in magic link query parameters for native flow authentication Add Default Basic + Magic Link Authentication Flow configuration
1 parent 21d7dcc commit 0b08c17

File tree

23 files changed

+2911
-20
lines changed

23 files changed

+2911
-20
lines changed

backend/.mockery.public.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@ packages:
220220
pkgname: githubmock
221221
filename: "{{.InterfaceName}}_mock.go"
222222

223+
github.com/asgardeo/thunder/internal/authn/magiclink:
224+
config:
225+
all: true
226+
dir: tests/mocks/authn/magiclinkmock
227+
structname: '{{.InterfaceName}}Mock'
228+
pkgname: magiclinkmock
229+
filename: "{{.InterfaceName}}_mock.go"
230+
223231
github.com/asgardeo/thunder/internal/authn/passkey:
224232
interfaces:
225233
PasskeyServiceInterface:
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
{
2+
"name": "Default Basic + Magic Link Authentication Flow",
3+
"handle": "default-basic-magic-link-flow",
4+
"flowType": "AUTHENTICATION",
5+
"nodes": [
6+
{
7+
"id": "start",
8+
"type": "START",
9+
"onSuccess": "prompt_credentials"
10+
},
11+
{
12+
"id": "prompt_credentials",
13+
"type": "PROMPT",
14+
"prompts": [
15+
{
16+
"inputs": [
17+
{
18+
"ref": "input_001",
19+
"identifier": "username",
20+
"type": "TEXT_INPUT",
21+
"required": true
22+
},
23+
{
24+
"ref": "input_002",
25+
"identifier": "password",
26+
"type": "PASSWORD_INPUT",
27+
"required": true
28+
}
29+
],
30+
"action": {
31+
"ref": "action_001",
32+
"nextNode": "basic_auth"
33+
}
34+
}
35+
]
36+
},
37+
{
38+
"id": "basic_auth",
39+
"type": "TASK_EXECUTION",
40+
"executor": {
41+
"name": "BasicAuthExecutor"
42+
},
43+
"onSuccess": "collect_email",
44+
"onIncomplete": "prompt_credentials"
45+
},
46+
{
47+
"id": "collect_email",
48+
"type": "TASK_EXECUTION",
49+
"executor": {
50+
"name": "AttributeCollector",
51+
"inputs": [
52+
{
53+
"ref": "input_003",
54+
"identifier": "email",
55+
"type": "EMAIL_INPUT",
56+
"required": false
57+
}
58+
]
59+
},
60+
"onSuccess": "send_magic_link"
61+
},
62+
{
63+
"id": "send_magic_link",
64+
"type": "TASK_EXECUTION",
65+
"properties": {
66+
"magicLinkURL": "https://localhost:3000/signin"
67+
},
68+
"executor": {
69+
"name": "MagicLinkAuthExecutor",
70+
"mode": "send"
71+
},
72+
"onSuccess": "verify_magic_link"
73+
},
74+
{
75+
"id": "verify_magic_link",
76+
"type": "TASK_EXECUTION",
77+
"executor": {
78+
"name": "MagicLinkAuthExecutor",
79+
"mode": "verify"
80+
},
81+
"onSuccess": "auth_assert"
82+
},
83+
{
84+
"id": "auth_assert",
85+
"type": "TASK_EXECUTION",
86+
"executor": {
87+
"name": "AuthAssertExecutor"
88+
},
89+
"onSuccess": "end"
90+
},
91+
{
92+
"id": "end",
93+
"type": "END"
94+
}
95+
]
96+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"name": "Default Magic Link Authentication Flow",
3+
"handle": "default-magic-link-flow",
4+
"flowType": "AUTHENTICATION",
5+
"nodes": [
6+
{
7+
"id": "start",
8+
"type": "START",
9+
"onSuccess": "prompt_email"
10+
},
11+
{
12+
"id": "prompt_email",
13+
"type": "PROMPT",
14+
"prompts": [
15+
{
16+
"inputs": [
17+
{
18+
"ref": "input_001",
19+
"identifier": "email",
20+
"type": "EMAIL_INPUT",
21+
"required": true
22+
}
23+
],
24+
"action": {
25+
"ref": "magic_link_action",
26+
"nextNode": "send_magic_link"
27+
}
28+
}
29+
]
30+
},
31+
{
32+
"id": "send_magic_link",
33+
"type": "TASK_EXECUTION",
34+
"properties": {
35+
"magicLinkURL": "https://localhost:3000/signin"
36+
},
37+
"executor": {
38+
"name": "MagicLinkAuthExecutor",
39+
"mode": "send"
40+
},
41+
"onSuccess": "verify_magic_link"
42+
},
43+
{
44+
"id": "verify_magic_link",
45+
"type": "TASK_EXECUTION",
46+
"executor": {
47+
"name": "MagicLinkAuthExecutor",
48+
"mode": "verify"
49+
},
50+
"onSuccess": "auth_assert"
51+
},
52+
{
53+
"id": "auth_assert",
54+
"type": "TASK_EXECUTION",
55+
"executor": {
56+
"name": "AuthAssertExecutor"
57+
},
58+
"onSuccess": "end"
59+
},
60+
{
61+
"id": "end",
62+
"type": "END"
63+
}
64+
]
65+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
id: "magic-link"
2+
displayName: "Magic Link Sign-In Email"
3+
scenario: "MAGIC_LINK"
4+
type: "email"
5+
subject: "Sign in to your account"
6+
contentType: "text/html"
7+
body: |
8+
<!DOCTYPE html>
9+
<html>
10+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
11+
<h2>Sign in to your account</h2>
12+
<p>Use the secure link below to sign in:</p>
13+
<p><a href="{{ctx(magicLink)}}" style="display: inline-block; padding: 10px 20px;
14+
background-color: #ff7300; color: #fff; text-decoration: none;
15+
border-radius: 4px;">Sign in</a></p>
16+
<p>This link expires in {{ctx(expiryMinutes)}} minutes.</p>
17+
<p>If the button above doesn't work, copy and paste the following link into your browser:</p>
18+
<p style="word-break: break-all;"><a href="{{ctx(magicLink)}}">{{ctx(magicLink)}}</a></p>
19+
<p>If you did not request this, you can safely ignore this email.</p>
20+
</body>
21+
</html>

backend/cmd/server/servicemanager.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,6 @@ func registerServices(mux *http.ServeMux) jwt.JWTServiceInterface {
180180
// Initialize user provider based on configuration
181181
userProvider := userprovider.InitializeUserProvider(userService)
182182

183-
// Initialize authentication services.
184-
_, authSvcRegistry := authn.Initialize(
185-
mux, mcpServer, idpService, jwtService, userService,
186-
userProvider, otpService, authnProvider, consentService,
187-
)
188-
189-
attributeCacheService := attributecache.Initialize()
190-
191-
// Initialize flow and executor services.
192-
flowFactory, graphCache := flowcore.Initialize()
193183
var emailClient email.EmailClientInterface
194184
emailClient, err = email.Initialize()
195185
if err != nil {
@@ -201,6 +191,17 @@ func registerServices(mux *http.ServeMux) jwt.JWTServiceInterface {
201191
if err != nil {
202192
logger.Fatal("Failed to initialize template service", log.Error(err))
203193
}
194+
195+
// Initialize authentication services.
196+
_, authSvcRegistry := authn.Initialize(
197+
mux, mcpServer, idpService, jwtService, userService,
198+
userProvider, otpService, authnProvider, consentService, emailClient, templateService,
199+
)
200+
201+
attributeCacheService := attributecache.Initialize()
202+
203+
// Initialize flow and executor services.
204+
flowFactory, graphCache := flowcore.Initialize()
204205
execRegistry := executor.Initialize(flowFactory, ouService,
205206
idpService, otpService, jwtService, authSvcRegistry, authZService, userSchemaService, observabilitySvc,
206207
groupService, roleService, userProvider, attributeCacheService, emailClient, templateService)

backend/internal/authn/common/constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const DefaultHTTPTimeout = 5 * time.Second
2929
const (
3030
AuthenticatorCredentials = "CredentialsAuthenticator"
3131
AuthenticatorSMSOTP = "SMSOTPAuthenticator"
32+
AuthenticatorMagicLink = "MagicLinkAuthenticator"
3233
AuthenticatorGoogle = "GoogleOIDCAuthenticator"
3334
AuthenticatorGithub = "GithubOAuthAuthenticator"
3435
AuthenticatorOAuth = "OAuthAuthenticator"

backend/internal/authn/common/error_constants.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package common
2121
import (
2222
"github.com/asgardeo/thunder/internal/system/error/apierror"
2323
"github.com/asgardeo/thunder/internal/system/error/serviceerror"
24+
"github.com/asgardeo/thunder/internal/system/i18n/core"
2425
)
2526

2627
// API errors
@@ -90,6 +91,19 @@ var (
9091
Error: "User not found",
9192
ErrorDescription: "No user found with the provided attributes",
9293
}
94+
// ErrorUserNotFoundWithI18n is the i18n version of ErrorUserNotFound.
95+
ErrorUserNotFoundWithI18n = serviceerror.I18nServiceError{
96+
Type: serviceerror.ClientErrorType,
97+
Code: "AUTHN-1008",
98+
Error: core.I18nMessage{
99+
Key: "authn.error.user_not_found",
100+
DefaultValue: "User not found",
101+
},
102+
ErrorDescription: core.I18nMessage{
103+
Key: "authn.error.user_not_found_description",
104+
DefaultValue: "No user found with the provided attributes",
105+
},
106+
}
93107
// ErrorInvalidAssertion is the error returned when the provided assertion token is invalid.
94108
ErrorInvalidAssertion = serviceerror.ServiceError{
95109
Type: serviceerror.ClientErrorType,

backend/internal/authn/handler_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func TestAuthenticationHandlerTestSuite(t *testing.T) {
4848

4949
func (suite *AuthenticationHandlerTestSuite) SetupTest() {
5050
suite.mockService = NewAuthenticationServiceInterfaceMock(suite.T())
51+
suite.handler = newAuthenticationHandler(suite.mockService)
5152
suite.handler = &authenticationHandler{
5253
authService: suite.mockService,
5354
}

backend/internal/authn/init.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"github.com/asgardeo/thunder/internal/authn/credentials"
2929
"github.com/asgardeo/thunder/internal/authn/github"
3030
"github.com/asgardeo/thunder/internal/authn/google"
31+
"github.com/asgardeo/thunder/internal/authn/magiclink"
3132
"github.com/asgardeo/thunder/internal/authn/oauth"
3233
"github.com/asgardeo/thunder/internal/authn/oidc"
3334
"github.com/asgardeo/thunder/internal/authn/otp"
@@ -37,8 +38,10 @@ import (
3738
consentmgt "github.com/asgardeo/thunder/internal/consent"
3839
"github.com/asgardeo/thunder/internal/idp"
3940
"github.com/asgardeo/thunder/internal/notification"
41+
"github.com/asgardeo/thunder/internal/system/email"
4042
"github.com/asgardeo/thunder/internal/system/jose/jwt"
4143
"github.com/asgardeo/thunder/internal/system/middleware"
44+
"github.com/asgardeo/thunder/internal/system/template"
4245
"github.com/asgardeo/thunder/internal/user"
4346
"github.com/asgardeo/thunder/internal/userprovider"
4447
)
@@ -51,6 +54,7 @@ type AuthServiceRegistry struct {
5154
OIDCAuthnService oidc.OIDCAuthnServiceInterface
5255
GithubOAuthAuthnService github.GithubOAuthAuthnServiceInterface
5356
GoogleOIDCAuthnService google.GoogleOIDCAuthnServiceInterface
57+
MagicLinkAuthnService magiclink.MagicLinkAuthnServiceInterface
5458
AuthAssertGenerator assert.AuthAssertGeneratorInterface
5559
PasskeyService passkey.PasskeyServiceInterface
5660
ConsentEnforcerService consent.ConsentEnforcerServiceInterface
@@ -67,9 +71,11 @@ func Initialize(
6771
otpSvc notification.OTPServiceInterface,
6872
authnProvider authnprovider.AuthnProviderInterface,
6973
consentSvc consentmgt.ConsentServiceInterface,
74+
emailClient email.EmailClientInterface,
75+
templateService template.TemplateServiceInterface,
7076
) (AuthenticationServiceInterface, *AuthServiceRegistry) {
7177
authServiceRegistry := createAuthServiceRegistry(idpSvc, jwtSvc,
72-
userSvc, userProvider, otpSvc, authnProvider, consentSvc)
78+
userSvc, userProvider, otpSvc, authnProvider, consentSvc, emailClient, templateService)
7379
authnService := newAuthenticationService(
7480
idpSvc,
7581
jwtSvc,
@@ -103,8 +109,10 @@ func createAuthServiceRegistry(
103109
otpSvc notification.OTPServiceInterface,
104110
authnProvider authnprovider.AuthnProviderInterface,
105111
consentSvc consentmgt.ConsentServiceInterface,
112+
emailClient email.EmailClientInterface,
113+
templateService template.TemplateServiceInterface,
106114
) *AuthServiceRegistry {
107-
return &AuthServiceRegistry{
115+
registry := &AuthServiceRegistry{
108116
CredentialsAuthnService: credentials.Initialize(authnProvider),
109117
OTPAuthnService: otp.Initialize(otpSvc, userProvider),
110118
OAuthAuthnService: oauth.Initialize(idpSvc, userProvider),
@@ -115,6 +123,13 @@ func createAuthServiceRegistry(
115123
AuthAssertGenerator: assert.Initialize(),
116124
ConsentEnforcerService: consent.Initialize(consentSvc, jwtSvc),
117125
}
126+
127+
// Only register Magic Link authenticator if email client is configured.
128+
if emailClient != nil {
129+
registry.MagicLinkAuthnService = magiclink.Initialize(jwtSvc, emailClient, userProvider, templateService)
130+
}
131+
132+
return registry
118133
}
119134

120135
// registerRoutes registers the routes for the authentication.

0 commit comments

Comments
 (0)