@draftlab/auth 0.2.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import { Provider } from "./provider.js";
2
+ import { Oauth2UserData, Oauth2WrappedConfig } from "./oauth2.js";
3
+
4
+ //#region src/provider/discord.d.ts
5
+
6
+ /**
7
+ * Configuration options for Discord OAuth 2.0 provider.
8
+ * Extends the base OAuth 2.0 configuration with Discord-specific documentation.
9
+ */
10
+ interface DiscordConfig extends Oauth2WrappedConfig {
11
+ /**
12
+ * Discord OAuth 2.0 client ID from Discord Developer Portal.
13
+ * Found in your Discord application settings.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * {
18
+ * clientID: "1234567890123456789"
19
+ * }
20
+ * ```
21
+ */
22
+ readonly clientID: string;
23
+ /**
24
+ * Discord OAuth 2.0 client secret from Discord Developer Portal.
25
+ * Keep this secure and never expose it to client-side code.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * {
30
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET
31
+ * }
32
+ * ```
33
+ */
34
+ readonly clientSecret: string;
35
+ /**
36
+ * Discord OAuth scopes to request access for.
37
+ * Determines what data and actions your app can access.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * {
42
+ * scopes: [
43
+ * "identify", // Basic user information
44
+ * "email", // Email address
45
+ * "guilds", // User's Discord servers
46
+ * "connections" // Connected accounts (Steam, etc.)
47
+ * ]
48
+ * }
49
+ * ```
50
+ */
51
+ readonly scopes: string[];
52
+ /**
53
+ * Additional query parameters for Discord OAuth authorization.
54
+ * Useful for Discord-specific options like permissions.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * {
59
+ * query: {
60
+ * permissions: "8", // Administrator permission
61
+ * guild_id: "123456789", // Pre-select specific guild
62
+ * disable_guild_select: "true" // Disable guild selection
63
+ * }
64
+ * }
65
+ * ```
66
+ */
67
+ readonly query?: Record<string, string>;
68
+ }
69
+ /**
70
+ * Creates a Discord OAuth 2.0 authentication provider.
71
+ * Use this when you need access tokens to call Discord APIs on behalf of the user.
72
+ *
73
+ * @param config - Discord OAuth 2.0 configuration
74
+ * @returns OAuth 2.0 provider configured for Discord
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * // Basic Discord authentication
79
+ * const basicDiscord = DiscordProvider({
80
+ * clientID: process.env.DISCORD_CLIENT_ID,
81
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET
82
+ * })
83
+ *
84
+ * // Discord with specific scopes
85
+ * const discordWithScopes = DiscordProvider({
86
+ * clientID: process.env.DISCORD_CLIENT_ID,
87
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
88
+ * scopes: [
89
+ * "identify",
90
+ * "email",
91
+ * "guilds",
92
+ * "connections"
93
+ * ]
94
+ * })
95
+ *
96
+ * // Discord bot integration
97
+ * const discordBot = DiscordProvider({
98
+ * clientID: process.env.DISCORD_CLIENT_ID,
99
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
100
+ * scopes: ["bot", "guilds"],
101
+ * query: {
102
+ * permissions: "2048" // Send Messages permission
103
+ * }
104
+ * })
105
+ *
106
+ * // Using the access token to fetch data
107
+ * export default issuer({
108
+ * providers: { discord: discordWithScopes },
109
+ * success: async (ctx, value) => {
110
+ * if (value.provider === "discord") {
111
+ * const token = value.tokenset.access
112
+ *
113
+ * // Get user profile
114
+ * const userRes = await fetch('https://discord.com/api/users/@me', {
115
+ * headers: { Authorization: `Bearer ${token}` }
116
+ * })
117
+ * const user = await userRes.json()
118
+ *
119
+ * // Get user guilds (if guilds scope granted)
120
+ * const guildsRes = await fetch('https://discord.com/api/users/@me/guilds', {
121
+ * headers: { Authorization: `Bearer ${token}` }
122
+ * })
123
+ * const guilds = await guildsRes.json()
124
+ *
125
+ * return ctx.subject("user", {
126
+ * discordId: user.id,
127
+ * username: user.username,
128
+ * discriminator: user.discriminator,
129
+ * email: user.email,
130
+ * avatar: user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` : null,
131
+ * guildCount: guilds.length
132
+ * })
133
+ * }
134
+ * }
135
+ * })
136
+ * ```
137
+ */
138
+ declare const DiscordProvider: (config: DiscordConfig) => Provider<Oauth2UserData>;
139
+ //#endregion
140
+ export { DiscordConfig, DiscordProvider };
@@ -0,0 +1,85 @@
1
+ import { Oauth2Provider } from "./oauth2.js";
2
+
3
+ //#region src/provider/discord.ts
4
+ /**
5
+ * Creates a Discord OAuth 2.0 authentication provider.
6
+ * Use this when you need access tokens to call Discord APIs on behalf of the user.
7
+ *
8
+ * @param config - Discord OAuth 2.0 configuration
9
+ * @returns OAuth 2.0 provider configured for Discord
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // Basic Discord authentication
14
+ * const basicDiscord = DiscordProvider({
15
+ * clientID: process.env.DISCORD_CLIENT_ID,
16
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET
17
+ * })
18
+ *
19
+ * // Discord with specific scopes
20
+ * const discordWithScopes = DiscordProvider({
21
+ * clientID: process.env.DISCORD_CLIENT_ID,
22
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
23
+ * scopes: [
24
+ * "identify",
25
+ * "email",
26
+ * "guilds",
27
+ * "connections"
28
+ * ]
29
+ * })
30
+ *
31
+ * // Discord bot integration
32
+ * const discordBot = DiscordProvider({
33
+ * clientID: process.env.DISCORD_CLIENT_ID,
34
+ * clientSecret: process.env.DISCORD_CLIENT_SECRET,
35
+ * scopes: ["bot", "guilds"],
36
+ * query: {
37
+ * permissions: "2048" // Send Messages permission
38
+ * }
39
+ * })
40
+ *
41
+ * // Using the access token to fetch data
42
+ * export default issuer({
43
+ * providers: { discord: discordWithScopes },
44
+ * success: async (ctx, value) => {
45
+ * if (value.provider === "discord") {
46
+ * const token = value.tokenset.access
47
+ *
48
+ * // Get user profile
49
+ * const userRes = await fetch('https://discord.com/api/users/@me', {
50
+ * headers: { Authorization: `Bearer ${token}` }
51
+ * })
52
+ * const user = await userRes.json()
53
+ *
54
+ * // Get user guilds (if guilds scope granted)
55
+ * const guildsRes = await fetch('https://discord.com/api/users/@me/guilds', {
56
+ * headers: { Authorization: `Bearer ${token}` }
57
+ * })
58
+ * const guilds = await guildsRes.json()
59
+ *
60
+ * return ctx.subject("user", {
61
+ * discordId: user.id,
62
+ * username: user.username,
63
+ * discriminator: user.discriminator,
64
+ * email: user.email,
65
+ * avatar: user.avatar ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` : null,
66
+ * guildCount: guilds.length
67
+ * })
68
+ * }
69
+ * }
70
+ * })
71
+ * ```
72
+ */
73
+ const DiscordProvider = (config) => {
74
+ return Oauth2Provider({
75
+ ...config,
76
+ type: "discord",
77
+ endpoint: {
78
+ authorization: "https://discord.com/oauth2/authorize",
79
+ token: "https://discord.com/api/oauth2/token"
80
+ }
81
+ });
82
+ };
83
+
84
+ //#endregion
85
+ export { DiscordProvider };
@@ -0,0 +1,126 @@
1
+ import { Provider } from "./provider.js";
2
+ import { Oauth2UserData, Oauth2WrappedConfig } from "./oauth2.js";
3
+
4
+ //#region src/provider/linkedin.d.ts
5
+
6
+ /**
7
+ * Configuration options for LinkedIn OAuth 2.0 provider.
8
+ * Extends the base OAuth 2.0 configuration with LinkedIn-specific documentation.
9
+ */
10
+ interface LinkedInConfig extends Oauth2WrappedConfig {
11
+ /**
12
+ * LinkedIn OAuth 2.0 client ID from LinkedIn Developer Console.
13
+ * Found in your LinkedIn app settings.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * {
18
+ * clientID: "78abc123456789"
19
+ * }
20
+ * ```
21
+ */
22
+ readonly clientID: string;
23
+ /**
24
+ * LinkedIn OAuth 2.0 client secret from LinkedIn Developer Console.
25
+ * Keep this secure and never expose it to client-side code.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * {
30
+ * clientSecret: process.env.LINKEDIN_CLIENT_SECRET
31
+ * }
32
+ * ```
33
+ */
34
+ readonly clientSecret: string;
35
+ /**
36
+ * LinkedIn OAuth scopes to request access for.
37
+ * Determines what data and actions your app can access.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * {
42
+ * scopes: [
43
+ * "r_liteprofile", // Basic profile information
44
+ * "r_emailaddress", // Email address
45
+ * "w_member_social", // Share content on behalf of user
46
+ * "r_organization_social" // Organization content access
47
+ * ]
48
+ * }
49
+ * ```
50
+ */
51
+ readonly scopes: string[];
52
+ /**
53
+ * Additional query parameters for LinkedIn OAuth authorization.
54
+ * Useful for LinkedIn-specific options.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * {
59
+ * query: {
60
+ * state: "custom-state-value" // Custom state parameter
61
+ * }
62
+ * }
63
+ * ```
64
+ */
65
+ readonly query?: Record<string, string>;
66
+ }
67
+ /**
68
+ * Creates a LinkedIn OAuth 2.0 authentication provider.
69
+ * Use this when you need access tokens to call LinkedIn APIs on behalf of the user.
70
+ *
71
+ * @param config - LinkedIn OAuth 2.0 configuration
72
+ * @returns OAuth 2.0 provider configured for LinkedIn
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * // Basic LinkedIn authentication
77
+ * const basicLinkedIn = LinkedInProvider({
78
+ * clientID: process.env.LINKEDIN_CLIENT_ID,
79
+ * clientSecret: process.env.LINKEDIN_CLIENT_SECRET
80
+ * })
81
+ *
82
+ * // LinkedIn with specific scopes
83
+ * const linkedInWithScopes = LinkedInProvider({
84
+ * clientID: process.env.LINKEDIN_CLIENT_ID,
85
+ * clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
86
+ * scopes: [
87
+ * "r_liteprofile",
88
+ * "r_emailaddress",
89
+ * "w_member_social"
90
+ * ]
91
+ * })
92
+ *
93
+ * // Using the access token to fetch data
94
+ * export default issuer({
95
+ * providers: { linkedin: linkedInWithScopes },
96
+ * success: async (ctx, value) => {
97
+ * if (value.provider === "linkedin") {
98
+ * const token = value.tokenset.access
99
+ *
100
+ * // Get user profile
101
+ * const profileRes = await fetch('https://api.linkedin.com/v2/people/~', {
102
+ * headers: { Authorization: `Bearer ${token}` }
103
+ * })
104
+ * const profile = await profileRes.json()
105
+ *
106
+ * // Get user email
107
+ * const emailRes = await fetch('https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))', {
108
+ * headers: { Authorization: `Bearer ${token}` }
109
+ * })
110
+ * const emailData = await emailRes.json()
111
+ *
112
+ * return ctx.subject("user", {
113
+ * linkedinId: profile.id,
114
+ * firstName: profile.localizedFirstName,
115
+ * lastName: profile.localizedLastName,
116
+ * email: emailData.elements[0]['handle~'].emailAddress,
117
+ * profileUrl: `https://www.linkedin.com/in/${profile.vanityName || profile.id}`
118
+ * })
119
+ * }
120
+ * }
121
+ * })
122
+ * ```
123
+ */
124
+ declare const LinkedInProvider: (config: LinkedInConfig) => Provider<Oauth2UserData>;
125
+ //#endregion
126
+ export { LinkedInConfig, LinkedInProvider };
@@ -0,0 +1,73 @@
1
+ import { Oauth2Provider } from "./oauth2.js";
2
+
3
+ //#region src/provider/linkedin.ts
4
+ /**
5
+ * Creates a LinkedIn OAuth 2.0 authentication provider.
6
+ * Use this when you need access tokens to call LinkedIn APIs on behalf of the user.
7
+ *
8
+ * @param config - LinkedIn OAuth 2.0 configuration
9
+ * @returns OAuth 2.0 provider configured for LinkedIn
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // Basic LinkedIn authentication
14
+ * const basicLinkedIn = LinkedInProvider({
15
+ * clientID: process.env.LINKEDIN_CLIENT_ID,
16
+ * clientSecret: process.env.LINKEDIN_CLIENT_SECRET
17
+ * })
18
+ *
19
+ * // LinkedIn with specific scopes
20
+ * const linkedInWithScopes = LinkedInProvider({
21
+ * clientID: process.env.LINKEDIN_CLIENT_ID,
22
+ * clientSecret: process.env.LINKEDIN_CLIENT_SECRET,
23
+ * scopes: [
24
+ * "r_liteprofile",
25
+ * "r_emailaddress",
26
+ * "w_member_social"
27
+ * ]
28
+ * })
29
+ *
30
+ * // Using the access token to fetch data
31
+ * export default issuer({
32
+ * providers: { linkedin: linkedInWithScopes },
33
+ * success: async (ctx, value) => {
34
+ * if (value.provider === "linkedin") {
35
+ * const token = value.tokenset.access
36
+ *
37
+ * // Get user profile
38
+ * const profileRes = await fetch('https://api.linkedin.com/v2/people/~', {
39
+ * headers: { Authorization: `Bearer ${token}` }
40
+ * })
41
+ * const profile = await profileRes.json()
42
+ *
43
+ * // Get user email
44
+ * const emailRes = await fetch('https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))', {
45
+ * headers: { Authorization: `Bearer ${token}` }
46
+ * })
47
+ * const emailData = await emailRes.json()
48
+ *
49
+ * return ctx.subject("user", {
50
+ * linkedinId: profile.id,
51
+ * firstName: profile.localizedFirstName,
52
+ * lastName: profile.localizedLastName,
53
+ * email: emailData.elements[0]['handle~'].emailAddress,
54
+ * profileUrl: `https://www.linkedin.com/in/${profile.vanityName || profile.id}`
55
+ * })
56
+ * }
57
+ * }
58
+ * })
59
+ * ```
60
+ */
61
+ const LinkedInProvider = (config) => {
62
+ return Oauth2Provider({
63
+ ...config,
64
+ type: "linkedin",
65
+ endpoint: {
66
+ authorization: "https://www.linkedin.com/oauth/v2/authorization",
67
+ token: "https://www.linkedin.com/oauth/v2/accessToken"
68
+ }
69
+ });
70
+ };
71
+
72
+ //#endregion
73
+ export { LinkedInProvider };
@@ -0,0 +1,89 @@
1
+ import { Provider } from "./provider.js";
2
+
3
+ //#region src/provider/magiclink.d.ts
4
+
5
+ /**
6
+ * Configuration options for the Magic Link authentication provider.
7
+ *
8
+ * @template Claims - Type of claims collected during authentication (email, phone, etc.)
9
+ */
10
+ interface MagicLinkConfig<Claims extends Record<string, string> = Record<string, string>> {
11
+ /**
12
+ * Token expiration time in seconds.
13
+ * After this time, the magic link becomes invalid.
14
+ *
15
+ * @default 900 (15 minutes)
16
+ */
17
+ readonly expiry?: number;
18
+ /**
19
+ * Request handler for rendering the magic link UI.
20
+ * Handles both the initial claim collection and "check your email" screens.
21
+ *
22
+ * @param req - The HTTP request object
23
+ * @param state - Current authentication state
24
+ * @param form - Form data from POST requests (if any)
25
+ * @param error - Authentication error to display (if any)
26
+ * @returns Promise resolving to the authentication page response
27
+ */
28
+ request: (req: Request, state: MagicLinkState, form?: FormData, error?: MagicLinkError) => Promise<Response>;
29
+ /**
30
+ * Callback for sending magic links to users.
31
+ * Should handle delivery via email, SMS, or other communication channels.
32
+ *
33
+ * @param claims - User claims containing contact information
34
+ * @param magicUrl - The magic link URL to send
35
+ * @returns Promise resolving to undefined on success, or error object on failure
36
+ */
37
+ sendLink: (claims: Claims, magicUrl: string) => Promise<MagicLinkError | undefined>;
38
+ }
39
+ /**
40
+ * Authentication flow states for the magic link provider.
41
+ * The provider transitions between these states during authentication.
42
+ */
43
+ type MagicLinkState = {
44
+ /** Initial state: user enters their claims (email, phone, etc.) */
45
+ readonly type: "start";
46
+ } | {
47
+ /** Link sent state: user checks their email/phone */
48
+ readonly type: "sent";
49
+ /** Whether this is a resend request */
50
+ readonly resend?: boolean;
51
+ /** The secure token for verification */
52
+ readonly token: string;
53
+ /** User claims collected during the start phase */
54
+ readonly claims: Record<string, string>;
55
+ };
56
+ /**
57
+ * Possible errors during magic link authentication.
58
+ */
59
+ type MagicLinkError = {
60
+ /** The magic link is invalid or expired */
61
+ readonly type: "invalid_link";
62
+ } | {
63
+ /** A user claim is invalid or missing */
64
+ readonly type: "invalid_claim";
65
+ /** The claim field that failed validation */
66
+ readonly key: string;
67
+ /** The invalid value or error description */
68
+ readonly value: string;
69
+ };
70
+ /**
71
+ * User data returned by successful magic link authentication.
72
+ *
73
+ * @template Claims - Type of claims collected during authentication
74
+ */
75
+ interface MagicLinkUserData<Claims extends Record<string, string> = Record<string, string>> {
76
+ /** The verified claims collected during authentication */
77
+ readonly claims: Claims;
78
+ }
79
+ /**
80
+ * Creates a Magic Link authentication provider.
81
+ * Implements a flexible claim-based authentication flow with magic link verification.
82
+ *
83
+ * @template Claims - Type of claims to collect (email, phone, username, etc.)
84
+ * @param config - Magic Link provider configuration
85
+ * @returns Provider instance implementing magic link authentication
86
+ */
87
+ declare const MagicLinkProvider: <Claims extends Record<string, string> = Record<string, string>>(config: MagicLinkConfig<Claims>) => Provider<MagicLinkUserData<Claims>>;
88
+ //#endregion
89
+ export { MagicLinkConfig, MagicLinkError, MagicLinkProvider, MagicLinkState, MagicLinkUserData };
@@ -0,0 +1,87 @@
1
+ import { generateUnbiasedDigits, timingSafeCompare } from "../random.js";
2
+
3
+ //#region src/provider/magiclink.ts
4
+ /**
5
+ * Creates a Magic Link authentication provider.
6
+ * Implements a flexible claim-based authentication flow with magic link verification.
7
+ *
8
+ * @template Claims - Type of claims to collect (email, phone, username, etc.)
9
+ * @param config - Magic Link provider configuration
10
+ * @returns Provider instance implementing magic link authentication
11
+ */
12
+ const MagicLinkProvider = (config) => {
13
+ /**
14
+ * Generates a cryptographically secure token.
15
+ */
16
+ const generateToken = () => {
17
+ return generateUnbiasedDigits(32);
18
+ };
19
+ return {
20
+ type: "magiclink",
21
+ init(routes, ctx) {
22
+ /**
23
+ * Transitions between authentication states and renders the appropriate UI.
24
+ */
25
+ const transition = async (c, nextState, formData, error) => {
26
+ await ctx.set(c, "provider", 3600 * 24, nextState);
27
+ const response = await config.request(c.request, nextState, formData, error);
28
+ return ctx.forward(c, response);
29
+ };
30
+ /**
31
+ * GET /authorize - Display initial claim collection form
32
+ */
33
+ routes.get("/authorize", async (c) => {
34
+ return transition(c, { type: "start" });
35
+ });
36
+ /**
37
+ * POST /authorize - Handle form submissions and state transitions
38
+ */
39
+ routes.post("/authorize", async (c) => {
40
+ const formData = await c.formData();
41
+ const action = formData.get("action")?.toString();
42
+ if (action === "request" || action === "resend") {
43
+ const token = generateToken();
44
+ const formEntries = Object.fromEntries(formData);
45
+ const { action: _,...claims } = formEntries;
46
+ const baseUrl = new URL(c.request.url).origin;
47
+ const magicUrl = new URL(`/auth/${ctx.name}/verify`, baseUrl);
48
+ magicUrl.searchParams.set("token", token);
49
+ for (const [key, value] of Object.entries(claims)) if (typeof value === "string") magicUrl.searchParams.set(key, value);
50
+ const sendError = await config.sendLink(claims, magicUrl.toString());
51
+ if (sendError) return transition(c, { type: "start" }, formData, sendError);
52
+ return transition(c, {
53
+ type: "sent",
54
+ resend: action === "resend",
55
+ claims,
56
+ token
57
+ }, formData);
58
+ }
59
+ return transition(c, { type: "start" });
60
+ });
61
+ /**
62
+ * GET /verify - Handle magic link clicks
63
+ */
64
+ routes.get("/verify", async (c) => {
65
+ const url = new URL(c.request.url);
66
+ const token = url.searchParams.get("token");
67
+ const storedState = await ctx.get(c, "provider");
68
+ if (!token || !storedState || storedState.type !== "sent") return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
69
+ if (!timingSafeCompare(storedState.token, token)) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
70
+ const urlClaims = {};
71
+ for (const [key, value] of url.searchParams) if (key !== "token" && value) urlClaims[key] = value;
72
+ const claimsMatch = Object.keys(storedState.claims).every((key) => {
73
+ const urlValue = urlClaims[key];
74
+ const storedValue = storedState.claims[key];
75
+ if (!urlValue || !storedValue) return false;
76
+ return timingSafeCompare(storedValue, urlValue);
77
+ });
78
+ if (!claimsMatch) return transition(c, { type: "start" }, void 0, { type: "invalid_link" });
79
+ await ctx.unset(c, "provider");
80
+ return await ctx.success(c, { claims: storedState.claims });
81
+ });
82
+ }
83
+ };
84
+ };
85
+
86
+ //#endregion
87
+ export { MagicLinkProvider };
@@ -0,0 +1,172 @@
1
+ import { Provider } from "./provider.js";
2
+ import { Oauth2UserData, Oauth2WrappedConfig } from "./oauth2.js";
3
+
4
+ //#region src/provider/microsoft.d.ts
5
+
6
+ /**
7
+ * Configuration options for Microsoft OAuth 2.0 provider.
8
+ * Extends the base OAuth 2.0 configuration with Microsoft-specific documentation.
9
+ */
10
+ interface MicrosoftConfig extends Oauth2WrappedConfig {
11
+ /**
12
+ * Microsoft Azure AD tenant ID or tenant type.
13
+ * Determines which types of accounts can sign in.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * {
18
+ * tenant: "common" // Personal + work/school accounts
19
+ * // or
20
+ * tenant: "organizations" // Work/school accounts only
21
+ * // or
22
+ * tenant: "consumers" // Personal accounts only
23
+ * // or
24
+ * tenant: "12345678-1234-1234-1234-123456789012" // Specific tenant
25
+ * }
26
+ * ```
27
+ */
28
+ readonly tenant: string;
29
+ /**
30
+ * Microsoft OAuth 2.0 client ID from Azure App Registration.
31
+ * Found in your Azure portal app registration.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * {
36
+ * clientID: "12345678-1234-1234-1234-123456789012"
37
+ * }
38
+ * ```
39
+ */
40
+ readonly clientID: string;
41
+ /**
42
+ * Microsoft OAuth 2.0 client secret from Azure App Registration.
43
+ * Keep this secure and never expose it to client-side code.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * {
48
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET
49
+ * }
50
+ * ```
51
+ */
52
+ readonly clientSecret: string;
53
+ /**
54
+ * Microsoft OAuth scopes to request access for.
55
+ * Determines what data and actions your app can access via Microsoft Graph.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * {
60
+ * scopes: [
61
+ * "openid", // OpenID Connect sign-in
62
+ * "profile", // Basic profile
63
+ * "email", // Email address
64
+ * "User.Read", // Read user profile
65
+ * "Mail.Read", // Read user mail
66
+ * "Calendars.Read" // Read user calendars
67
+ * ]
68
+ * }
69
+ * ```
70
+ */
71
+ readonly scopes: string[];
72
+ /**
73
+ * Additional query parameters for Microsoft OAuth authorization.
74
+ * Useful for Microsoft-specific options like domain hints.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * {
79
+ * query: {
80
+ * domain_hint: "contoso.com", // Pre-fill domain
81
+ * login_hint: "user@contoso.com", // Pre-fill username
82
+ * prompt: "consent" // Force consent screen
83
+ * }
84
+ * }
85
+ * ```
86
+ */
87
+ readonly query?: Record<string, string>;
88
+ }
89
+ /**
90
+ * Creates a Microsoft OAuth 2.0 authentication provider.
91
+ * Use this when you need access tokens to call Microsoft Graph APIs on behalf of the user.
92
+ *
93
+ * @param config - Microsoft OAuth 2.0 configuration
94
+ * @returns OAuth 2.0 provider configured for Microsoft
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * // Basic Microsoft authentication (all account types)
99
+ * const basicMicrosoft = MicrosoftProvider({
100
+ * tenant: "common",
101
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
102
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET
103
+ * })
104
+ *
105
+ * // Work/school accounts only
106
+ * const workMicrosoft = MicrosoftProvider({
107
+ * tenant: "organizations",
108
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
109
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
110
+ * scopes: [
111
+ * "openid",
112
+ * "profile",
113
+ * "email",
114
+ * "User.Read",
115
+ * "Mail.Read"
116
+ * ]
117
+ * })
118
+ *
119
+ * // Specific tenant with advanced scopes
120
+ * const enterpriseMicrosoft = MicrosoftProvider({
121
+ * tenant: "12345678-1234-1234-1234-123456789012",
122
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
123
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
124
+ * scopes: [
125
+ * "openid",
126
+ * "profile",
127
+ * "email",
128
+ * "User.Read",
129
+ * "Directory.Read.All",
130
+ * "Sites.Read.All"
131
+ * ],
132
+ * query: {
133
+ * domain_hint: "contoso.com"
134
+ * }
135
+ * })
136
+ *
137
+ * // Using the access token to fetch data
138
+ * export default issuer({
139
+ * providers: { microsoft: workMicrosoft },
140
+ * success: async (ctx, value) => {
141
+ * if (value.provider === "microsoft") {
142
+ * const token = value.tokenset.access
143
+ *
144
+ * // Get user profile from Microsoft Graph
145
+ * const userRes = await fetch('https://graph.microsoft.com/v1.0/me', {
146
+ * headers: { Authorization: `Bearer ${token}` }
147
+ * })
148
+ * const user = await userRes.json()
149
+ *
150
+ * // Get user's manager (if available)
151
+ * const managerRes = await fetch('https://graph.microsoft.com/v1.0/me/manager', {
152
+ * headers: { Authorization: `Bearer ${token}` }
153
+ * })
154
+ * const manager = await managerRes.json()
155
+ *
156
+ * return ctx.subject("user", {
157
+ * microsoftId: user.id,
158
+ * displayName: user.displayName,
159
+ * email: user.mail || user.userPrincipalName,
160
+ * jobTitle: user.jobTitle,
161
+ * department: user.department,
162
+ * officeLocation: user.officeLocation,
163
+ * managerName: manager?.displayName
164
+ * })
165
+ * }
166
+ * }
167
+ * })
168
+ * ```
169
+ */
170
+ declare const MicrosoftProvider: (config: MicrosoftConfig) => Provider<Oauth2UserData>;
171
+ //#endregion
172
+ export { MicrosoftConfig, MicrosoftProvider };
@@ -0,0 +1,97 @@
1
+ import { Oauth2Provider } from "./oauth2.js";
2
+
3
+ //#region src/provider/microsoft.ts
4
+ /**
5
+ * Creates a Microsoft OAuth 2.0 authentication provider.
6
+ * Use this when you need access tokens to call Microsoft Graph APIs on behalf of the user.
7
+ *
8
+ * @param config - Microsoft OAuth 2.0 configuration
9
+ * @returns OAuth 2.0 provider configured for Microsoft
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // Basic Microsoft authentication (all account types)
14
+ * const basicMicrosoft = MicrosoftProvider({
15
+ * tenant: "common",
16
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
17
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET
18
+ * })
19
+ *
20
+ * // Work/school accounts only
21
+ * const workMicrosoft = MicrosoftProvider({
22
+ * tenant: "organizations",
23
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
24
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
25
+ * scopes: [
26
+ * "openid",
27
+ * "profile",
28
+ * "email",
29
+ * "User.Read",
30
+ * "Mail.Read"
31
+ * ]
32
+ * })
33
+ *
34
+ * // Specific tenant with advanced scopes
35
+ * const enterpriseMicrosoft = MicrosoftProvider({
36
+ * tenant: "12345678-1234-1234-1234-123456789012",
37
+ * clientID: process.env.MICROSOFT_CLIENT_ID,
38
+ * clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
39
+ * scopes: [
40
+ * "openid",
41
+ * "profile",
42
+ * "email",
43
+ * "User.Read",
44
+ * "Directory.Read.All",
45
+ * "Sites.Read.All"
46
+ * ],
47
+ * query: {
48
+ * domain_hint: "contoso.com"
49
+ * }
50
+ * })
51
+ *
52
+ * // Using the access token to fetch data
53
+ * export default issuer({
54
+ * providers: { microsoft: workMicrosoft },
55
+ * success: async (ctx, value) => {
56
+ * if (value.provider === "microsoft") {
57
+ * const token = value.tokenset.access
58
+ *
59
+ * // Get user profile from Microsoft Graph
60
+ * const userRes = await fetch('https://graph.microsoft.com/v1.0/me', {
61
+ * headers: { Authorization: `Bearer ${token}` }
62
+ * })
63
+ * const user = await userRes.json()
64
+ *
65
+ * // Get user's manager (if available)
66
+ * const managerRes = await fetch('https://graph.microsoft.com/v1.0/me/manager', {
67
+ * headers: { Authorization: `Bearer ${token}` }
68
+ * })
69
+ * const manager = await managerRes.json()
70
+ *
71
+ * return ctx.subject("user", {
72
+ * microsoftId: user.id,
73
+ * displayName: user.displayName,
74
+ * email: user.mail || user.userPrincipalName,
75
+ * jobTitle: user.jobTitle,
76
+ * department: user.department,
77
+ * officeLocation: user.officeLocation,
78
+ * managerName: manager?.displayName
79
+ * })
80
+ * }
81
+ * }
82
+ * })
83
+ * ```
84
+ */
85
+ const MicrosoftProvider = (config) => {
86
+ return Oauth2Provider({
87
+ ...config,
88
+ type: "microsoft",
89
+ endpoint: {
90
+ authorization: `https://login.microsoftonline.com/${config.tenant}/oauth2/v2.0/authorize`,
91
+ token: `https://login.microsoftonline.com/${config.tenant}/oauth2/v2.0/token`
92
+ }
93
+ });
94
+ };
95
+
96
+ //#endregion
97
+ export { MicrosoftProvider };
@@ -0,0 +1,41 @@
1
+ import { MagicLinkConfig } from "../provider/magiclink.js";
2
+
3
+ //#region src/ui/magiclink.d.ts
4
+
5
+ /**
6
+ * Type for customizable UI copy text
7
+ */
8
+ interface MagicLinkUICopy {
9
+ readonly email_placeholder: string;
10
+ readonly email_invalid: string;
11
+ readonly button_continue: string;
12
+ readonly link_info: string;
13
+ readonly link_sent: string;
14
+ readonly link_resent: string;
15
+ readonly link_didnt_get: string;
16
+ readonly link_resend: string;
17
+ }
18
+ /**
19
+ * Input mode for the contact field
20
+ */
21
+ type MagicLinkUIMode = "email" | "phone";
22
+ /**
23
+ * Configuration options for the MagicLinkUI component
24
+ */
25
+ interface MagicLinkUIOptions<Claims extends Record<string, string> = Record<string, string>> extends Pick<MagicLinkConfig<Claims>, "sendLink"> {
26
+ /**
27
+ * Input mode determining the type of contact information to collect
28
+ * @default "email"
29
+ */
30
+ readonly mode?: MagicLinkUIMode;
31
+ /**
32
+ * Custom text copy for UI labels, messages, and errors
33
+ */
34
+ readonly copy?: Partial<MagicLinkUICopy>;
35
+ }
36
+ /**
37
+ * Creates a complete UI configuration for Magic Link authentication
38
+ */
39
+ declare const MagicLinkUI: <Claims extends Record<string, string> = Record<string, string>>(options: MagicLinkUIOptions<Claims>) => MagicLinkConfig<Claims>;
40
+ //#endregion
41
+ export { MagicLinkUI, MagicLinkUICopy, MagicLinkUIMode, MagicLinkUIOptions };
@@ -0,0 +1,146 @@
1
+ import { Layout, renderToHTML } from "./base.js";
2
+ import { FormAlert } from "./form.js";
3
+ import { jsx, jsxs } from "preact/jsx-runtime";
4
+
5
+ //#region src/ui/magiclink.tsx
6
+ /**
7
+ * Default text copy for the Magic Link authentication UI
8
+ */
9
+ const DEFAULT_COPY = {
10
+ email_placeholder: "Email",
11
+ email_invalid: "Email address is not valid",
12
+ button_continue: "Send Magic Link",
13
+ link_info: "We'll send a secure link to your email.",
14
+ link_sent: "Magic link sent to ",
15
+ link_resent: "Magic link resent to ",
16
+ link_didnt_get: "Didn't get the email?",
17
+ link_resend: "Resend Magic Link"
18
+ };
19
+ /**
20
+ * Gets the appropriate error message for display
21
+ */
22
+ const getErrorMessage = (error, copy) => {
23
+ if (!error?.type) return void 0;
24
+ switch (error.type) {
25
+ case "invalid_link": return "This magic link is invalid or expired";
26
+ case "invalid_claim": return copy.email_invalid;
27
+ }
28
+ };
29
+ /**
30
+ * Gets the appropriate success message for display
31
+ */
32
+ const getSuccessMessage = (state, copy, mode) => {
33
+ if (state.type === "start" || !state.claims) return void 0;
34
+ const contact = state.claims[mode] || "";
35
+ const prefix = state.resend ? copy.link_resent : copy.link_sent;
36
+ return {
37
+ message: `${prefix}${contact}`,
38
+ contact
39
+ };
40
+ };
41
+ /**
42
+ * Creates a complete UI configuration for Magic Link authentication
43
+ */
44
+ const MagicLinkUI = (options) => {
45
+ const copy = {
46
+ ...DEFAULT_COPY,
47
+ ...options.copy
48
+ };
49
+ const mode = options.mode || "email";
50
+ /**
51
+ * Renders the start form for collecting contact information
52
+ */
53
+ const renderStart = (form, error, state) => {
54
+ const success = getSuccessMessage(state || { type: "start" }, copy, mode);
55
+ return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
56
+ "data-component": "form",
57
+ method: "post",
58
+ children: [
59
+ success ? /* @__PURE__ */ jsx(FormAlert, {
60
+ message: success.message,
61
+ color: "success"
62
+ }) : /* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error, copy) }),
63
+ /* @__PURE__ */ jsx("input", {
64
+ "data-component": "input",
65
+ type: mode === "email" ? "email" : "tel",
66
+ name: mode,
67
+ placeholder: copy.email_placeholder,
68
+ value: form?.get(mode)?.toString() || "",
69
+ autoComplete: mode,
70
+ required: true
71
+ }),
72
+ /* @__PURE__ */ jsx("input", {
73
+ type: "hidden",
74
+ name: "action",
75
+ value: "request"
76
+ }),
77
+ /* @__PURE__ */ jsx("button", {
78
+ "data-component": "button",
79
+ type: "submit",
80
+ children: copy.button_continue
81
+ }),
82
+ /* @__PURE__ */ jsx("p", {
83
+ "data-component": "description",
84
+ children: copy.link_info
85
+ })
86
+ ]
87
+ }) });
88
+ };
89
+ /**
90
+ * Renders the "check your email" page after magic link is sent
91
+ */
92
+ const renderSent = (_form, error, state) => {
93
+ const success = getSuccessMessage(state, copy, mode);
94
+ const contact = state.type === "sent" ? state.claims?.[mode] || "" : "";
95
+ return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
96
+ "data-component": "form",
97
+ method: "post",
98
+ children: [
99
+ /* @__PURE__ */ jsx("h2", {
100
+ "data-component": "title",
101
+ children: "Check your email"
102
+ }),
103
+ success ? /* @__PURE__ */ jsx(FormAlert, {
104
+ message: success.message,
105
+ color: "success"
106
+ }) : /* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error, copy) }),
107
+ /* @__PURE__ */ jsx("p", {
108
+ "data-component": "description",
109
+ children: "Click the link in your email to sign in."
110
+ }),
111
+ /* @__PURE__ */ jsx("input", {
112
+ name: "action",
113
+ type: "hidden",
114
+ value: "resend"
115
+ }),
116
+ /* @__PURE__ */ jsx("input", {
117
+ name: mode,
118
+ type: "hidden",
119
+ value: contact
120
+ }),
121
+ /* @__PURE__ */ jsx("div", {
122
+ "data-component": "form-footer",
123
+ children: /* @__PURE__ */ jsxs("span", { children: [
124
+ copy.link_didnt_get,
125
+ " ",
126
+ /* @__PURE__ */ jsx("button", {
127
+ type: "submit",
128
+ "data-component": "link",
129
+ children: copy.link_resend
130
+ })
131
+ ] })
132
+ })
133
+ ]
134
+ }) });
135
+ };
136
+ return {
137
+ sendLink: options.sendLink,
138
+ request: async (_req, state, form, error) => {
139
+ const html = renderToHTML(state.type === "start" ? renderStart(form, error, state) : renderSent(form, error, state));
140
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
141
+ }
142
+ };
143
+ };
144
+
145
+ //#endregion
146
+ export { MagicLinkUI };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@draftlab/auth",
3
- "version": "0.2.6",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Core implementation for @draftlab/auth",
6
6
  "author": "Matheus Pergoli",