Add Microsoft OAuth provider support#304
Conversation
Introduces Microsoft as a supported OAuth provider across backend and frontend. Adds Microsoft-specific config options, provider logic, user info retrieval, and UI icon/button for login. Updates configuration, provider initialization, and provider selection logic to include Microsoft.
WalkthroughMicrosoft OAuth authentication support was integrated throughout the application. This includes new environment variables, configuration fields, command-line flags, provider logic, user info retrieval, and frontend login UI updates. The Microsoft provider is now treated as a first-class OAuth option, alongside GitHub, Google, and Generic providers. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Frontend
participant Backend
participant MicrosoftOAuth
User->>Frontend: Click "Sign in with Microsoft"
Frontend->>Backend: Initiate OAuth ("microsoft" provider)
Backend->>MicrosoftOAuth: Redirect to Microsoft Auth URL
User->>MicrosoftOAuth: Authenticate & authorize
MicrosoftOAuth->>Backend: Redirect with auth code
Backend->>MicrosoftOAuth: Exchange code for token
Backend->>MicrosoftOAuth: Fetch user info (/me endpoint)
MicrosoftOAuth-->>Backend: Return user info (email, name)
Backend-->>Frontend: Complete login (issue session/token)
Frontend-->>User: User logged in as Microsoft account
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Note 🔌 MCP (Model Context Protocol) integration is now available in Early Access!Pro users can now connect to remote MCP servers under the Integrations page to get reviews and chat conversations that understand additional development context. ✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR comments)
Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (9)
.env.example (1)
15-21: Microsoft OAuth env vars added — consider sample defaults and linter alignment
- Good coverage: client id/secret/secret file + auth/token/user URLs + scopes.
- Consider adding commented sample defaults to reduce setup friction:
- MICROSOFT_AUTH_URL=https://login.microsoftonline.com/common/oauth2/v2.0/authorize
- MICROSOFT_TOKEN_URL=https://login.microsoftonline.com/common/oauth2/v2.0/token
- MICROSOFT_USER_URL=https://graph.microsoft.com/v1.0/me
- MICROSOFT_SCOPES=openid,profile,email,User.Read
- dotenv-linter flags UnorderedKey, but the file isn’t alphabetically ordered overall. Either ignore these warnings for grouping consistency or sort keys if the team prefers strict alphabetical order.
If desired, I can add commented examples and/or reorder keys per team convention.
frontend/src/components/icons/microsoft.tsx (1)
4-15: Optional: add basic a11y attributesFor decorative usage, default to non-focusable and hidden from AT; consumers can override via props.
<svg width={24} height={24} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + focusable="false" {...props} >internal/utils/utils.go (1)
168-168: Consider secret-file as valid configuration for provider checksCurrently, a provider is considered “configured” only if client secret is inlined. If teams use the secret file option, the provider won’t be recognized as configured. Extend the check to include secret file presence.
- return (config.GithubClientId != "" && config.GithubClientSecret != "") || (config.GoogleClientId != "" && config.GoogleClientSecret != "") || (config.MicrosoftClientId != "" && config.MicrosoftClientSecret != "") || (config.GenericClientId != "" && config.GenericClientSecret != "") + return (config.GithubClientId != "" && (config.GithubClientSecret != "" || config.GithubClientSecretFile != "")) || + (config.GoogleClientId != "" && (config.GoogleClientSecret != "" || config.GoogleClientSecretFile != "")) || + (config.MicrosoftClientId != "" && (config.MicrosoftClientSecret != "" || config.MicrosoftClientSecretFile != "")) || + (config.GenericClientId != "" && (config.GenericClientSecret != "" || config.GenericClientSecretFile != ""))If you prefer readability, I can refactor to a small helper like isProviderConfigured(id, secret, secretFile).
frontend/src/pages/login-page.tsx (1)
134-143: Optional: reduce duplication across provider buttonsThe four provider blocks are structurally identical. Consider a small map of provider → {title, icon} and iterate over configuredProviders to render, which will also make adding future providers trivial.
internal/providers/microsoft.go (1)
3-10: Group imports into std / third-party / internal blocksGo convention (enforced by
goimports/gci) is:import ( // std-lib // third-party // internal )Mixing
tinyauth/internal/...with std-lib symbols in the same block will trigger linter warnings.cmd/root.go (3)
73-92: Potential empty-slice sentinel – trim and filter scopes
strings.Split(config.MicrosoftScopes, ",")returns[]string{""}when the flag/env is unset.
Althoughproviders.NewProviderschecks for exactly that pattern, trimming white-space first avoids edge cases like" ".-MicrosoftScopes: strings.Split(config.MicrosoftScopes, ","), +MicrosoftScopes: utils.SplitAndTrim(config.MicrosoftScopes, ","),(Helper already exists for Google/Github; if not, add one.)
203-209: Flag descriptions: mention defaults to help operatorsYou already allow overriding auth / token / user URLs. Clarify that leaving them blank falls back to Microsoft’s public endpoints so users know they can skip these flags in common cases.
221-221: Keep root help text in sync
oauth-auto-redirectflag description now lists “microsoft” – great.
TherootCmd.Longstring (Lines 28-31) still says “Google, Github and any generic OAuth provider”. Update it in a follow-up commit.internal/providers/providers.go (1)
64-68: Trim empty elements when deciding on custom scopesWhen
MICROSOFT_SCOPES="", after the earlier split we may get[]string{""}plus whitespace variants. Replace the length check with a helper that trims empty strings:scopes := utils.FilterEmpty(config.MicrosoftScopes) if len(scopes) == 0 { scopes = MicrosoftScopes() }Prevents accidental
[" "]slipping through tooauth2.Config.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (8)
.env.example(1 hunks)cmd/root.go(5 hunks)frontend/src/components/icons/microsoft.tsx(1 hunks)frontend/src/pages/login-page.tsx(2 hunks)internal/providers/microsoft.go(1 hunks)internal/providers/providers.go(2 hunks)internal/types/config.go(3 hunks)internal/utils/utils.go(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-04-30T15:00:45.323Z
Learnt from: steveiliop56
PR: steveiliop56/tinyauth#122
File: internal/providers/github.go:64-66
Timestamp: 2025-04-30T15:00:45.323Z
Learning: GitHub's API provides user profile information through the `/user` endpoint, including name and login (username). The `GithubScopes()` function needs to include the `user` or `read:user` scope (in addition to `user:email`) to access this data.
Applied to files:
internal/providers/microsoft.go
🧬 Code Graph Analysis (3)
internal/types/config.go (1)
internal/providers/microsoft.go (1)
MicrosoftScopes(20-22)
cmd/root.go (1)
internal/utils/utils.go (1)
GetSecret(91-106)
internal/providers/microsoft.go (3)
internal/constants/constants.go (1)
Claims(4-9)internal/providers/google.go (1)
GetGoogleUser(24-56)internal/providers/github.go (1)
GetGithubUser(30-102)
🪛 dotenv-linter (3.3.0)
.env.example
[warning] 15-15: [UnorderedKey] The MICROSOFT_CLIENT_ID key should go before the PORT key
[warning] 16-16: [UnorderedKey] The MICROSOFT_CLIENT_SECRET key should go before the PORT key
[warning] 17-17: [UnorderedKey] The MICROSOFT_CLIENT_SECRET_FILE key should go before the PORT key
[warning] 18-18: [UnorderedKey] The MICROSOFT_AUTH_URL key should go before the MICROSOFT_CLIENT_ID key
[warning] 19-19: [UnorderedKey] The MICROSOFT_TOKEN_URL key should go before the PORT key
[warning] 20-20: [UnorderedKey] The MICROSOFT_USER_URL key should go before the PORT key
[warning] 21-21: [UnorderedKey] The MICROSOFT_SCOPES key should go before the MICROSOFT_TOKEN_URL key
🔇 Additional comments (5)
frontend/src/components/icons/microsoft.tsx (1)
1-17: LGTM: Microsoft icon component is clean and matches existing icon patternsNo functional issues found. Exports and sizing look consistent with other icons.
internal/types/config.go (2)
19-25: Config: Microsoft fields added — looks consistent
- Fields mirror Google/GitHub patterns and include endpoints and secret file.
- “Scopes” as string here (vs []string in OAuthConfig below) matches the Generic approach.
37-37: Validation updated to includemicrosoftin OAuthAutoRedirectGood addition. Matches frontend usage where auto-redirect may be set to "microsoft".
frontend/src/pages/login-page.tsx (2)
5-5: Import Microsoft icon — consistent with icon usage elsewhereImport path and named export look correct.
134-143: LGTM: Microsoft OAuth button matches existing provider button semantics
- Same loading/disabled logic and mutation key usage.
- Title and icon are aligned with other providers.
| func MicrosoftScopes() []string { | ||
| return []string{"openid", "profile", "email", "User.Read"} | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Missing offline_access in default scope list
To obtain refresh tokens from Microsoft you generally need offline_access. Consider:
-return []string{"openid", "profile", "email", "User.Read"}
+return []string{"openid", "profile", "email", "offline_access", "User.Read"}Without it, users will be forced to re-authenticate once the access token expires.
🤖 Prompt for AI Agents
In internal/providers/microsoft.go around lines 20 to 22, the MicrosoftScopes
function is missing the "offline_access" scope, which is necessary to obtain
refresh tokens and avoid forcing users to re-authenticate after access token
expiration. Add "offline_access" to the returned slice of scopes to ensure
refresh tokens are granted.
| res, err := client.Get(url) | ||
| if err != nil { | ||
| return user, err | ||
| } | ||
| defer res.Body.Close() |
There was a problem hiding this comment.
Handle non-200 HTTP responses
client.Get() may return 4xx/5xx responses that still read fine but contain an error payload. Check res.StatusCode before reading the body:
-res, err := client.Get(url)
+res, err := client.Get(url)
if err != nil {
return user, err
}
+if res.StatusCode != http.StatusOK {
+ return user, fmt.Errorf("microsoft graph returned %d", res.StatusCode)
+}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| res, err := client.Get(url) | |
| if err != nil { | |
| return user, err | |
| } | |
| defer res.Body.Close() | |
| res, err := client.Get(url) | |
| if err != nil { | |
| return user, err | |
| } | |
| if res.StatusCode != http.StatusOK { | |
| return user, fmt.Errorf("microsoft graph returned %d", res.StatusCode) | |
| } | |
| defer res.Body.Close() |
🤖 Prompt for AI Agents
In internal/providers/microsoft.go around lines 33 to 37, after calling
client.Get(url), check if res.StatusCode is not 200. If it is not 200, handle
the error appropriately by returning an error indicating the unexpected status
code before proceeding to read the response body. This ensures that non-200 HTTP
responses are properly handled and do not cause incorrect processing.
| // Prefer mail, fallback to UserPrincipalName | ||
| email := userInfo.Mail | ||
| if email == "" { | ||
| email = userInfo.UserPrincipalName | ||
| } | ||
| user.PreferredUsername = strings.Split(email, "@")[0] | ||
| user.Name = userInfo.DisplayName | ||
| user.Email = email |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Return an error if no email could be determined
mail and userPrincipalName can both be empty for some tenants, leading to an empty PreferredUsername and silent acceptance.
email := userInfo.Mail
if email == "" {
email = userInfo.UserPrincipalName
}
+if email == "" {
+ return user, errors.New("no email returned from Microsoft Graph")
+}This aligns with the GitHub path where absence of email is considered an error.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Prefer mail, fallback to UserPrincipalName | |
| email := userInfo.Mail | |
| if email == "" { | |
| email = userInfo.UserPrincipalName | |
| } | |
| user.PreferredUsername = strings.Split(email, "@")[0] | |
| user.Name = userInfo.DisplayName | |
| user.Email = email | |
| // Prefer mail, fallback to UserPrincipalName | |
| email := userInfo.Mail | |
| if email == "" { | |
| email = userInfo.UserPrincipalName | |
| } | |
| if email == "" { | |
| return user, errors.New("no email returned from Microsoft Graph") | |
| } | |
| user.PreferredUsername = strings.Split(email, "@")[0] | |
| user.Name = userInfo.DisplayName | |
| user.Email = email |
🤖 Prompt for AI Agents
In internal/providers/microsoft.go around lines 57 to 64, the code silently
accepts when both mail and UserPrincipalName are empty, resulting in an empty
PreferredUsername. Modify the code to check if both email sources are empty, and
if so, return an error indicating that no email could be determined. This
ensures consistent error handling aligned with the GitHub path and prevents
silent acceptance of incomplete user data.
| client := providers.Microsoft.GetClient() | ||
| log.Debug().Msg("Got client from microsoft") | ||
| user, err := GetMicrosoftUser(client, providers.Config.MicrosoftUserURL) | ||
| if err != nil { | ||
| return user, err | ||
| } | ||
| log.Debug().Msg("Got user from microsoft") | ||
| return user, nil |
There was a problem hiding this comment.
Propagate custom user-info URL only when non-empty
Passing an empty string as a variadic argument yields []string{""} which overrides the default inside GetMicrosoftUser. Add a guard:
var userURL []string
if providers.Config.MicrosoftUserURL != "" {
userURL = []string{providers.Config.MicrosoftUserURL}
}
user, err := GetMicrosoftUser(client, userURL...)Prevents "" from becoming an invalid request URL.
🤖 Prompt for AI Agents
In internal/providers/providers.go around lines 149 to 156, the code passes
providers.Config.MicrosoftUserURL directly to GetMicrosoftUser, which causes an
empty string to override the default URL. Fix this by adding a check: only
create a slice with MicrosoftUserURL if it is non-empty, then pass that slice
variadically to GetMicrosoftUser. This prevents passing an empty string and
avoids invalid request URLs.
| GithubClientId string | ||
| GithubClientSecret string | ||
| GoogleClientId string | ||
| GoogleClientSecret string | ||
| MicrosoftClientId string | ||
| MicrosoftClientSecret string | ||
| MicrosoftAuthURL string | ||
| MicrosoftTokenURL string | ||
| MicrosoftUserURL string | ||
| MicrosoftScopes []string | ||
| GenericClientId string | ||
| GenericClientSecret string | ||
| GenericScopes []string | ||
| GenericAuthURL string | ||
| GenericTokenURL string | ||
| GenericUserURL string | ||
| GenericSkipSSL bool | ||
| AppURL string | ||
| } |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Ensure Config → OAuthConfig mapping and scope parsing cover Microsoft
- OAuthConfig adds Microsoft fields + []string scopes. Confirm the wiring that:
- Splits Config.MicrosoftScopes (string) into []string for OAuthConfig.MicrosoftScopes (e.g., comma-separated).
- Applies defaults for Auth/Token/User URLs if unset (common/tenant-specific as applicable).
Run to locate and verify the mapping logic and defaults:
🏁 Script executed:
#!/bin/bash
# 1) Where Config is transformed into OAuthConfig
rg -n "OAuthConfig\s*{"
rg -n "MicrosoftClientId|MicrosoftClientSecret|MicrosoftAuthURL|MicrosoftTokenURL|MicrosoftUserURL|MicrosoftScopes" internal
# 2) Where scopes are parsed from config (string -> []string), similar to GenericScopes
rg -n "MicrosoftScopes.*\[\]string|strings\.Split\(.+microsoft-scopes"
# 3) Where default MS endpoints are set if empty (providers/microsoft.go or providers.go)
rg -n "microsoft.*(authorize|token|user|graph|login\.microsoftonline\.com|/v2\.0|/me)" internalLength of output: 3162
🏁 Script executed:
#!/bin/bash
set -e
# Dump raw Config and OAuthConfig definitions to inspect field types and any mapping helpers
echo "=== internal/types/config.go:1-60 ==="
sed -n '1,60p' internal/types/config.go
echo
echo "=== internal/types/config.go:60-120 ==="
sed -n '60,120p' internal/types/config.go
# Search for any conversion helpers (methods that turn Config into OAuthConfig)
rg -n "func .*OAuthConfig" -n internal/types/config.go || echo "No direct converter found"
rg -n "Split" -n internal/types/config.go || echo "No strings.Split in config.go"Length of output: 5614
🏁 Script executed:
#!/bin/bash
set -e
# Show the Microsoft provider setup in providers/providers.go
echo "=== internal/providers/providers.go (Lines 1-120) ==="
sed -n '1,120p' internal/providers/providers.go
echo
echo "=== internal/providers/providers.go (Lines 120-200) ==="
sed -n '120,200p' internal/providers/providers.go
# Show the MicrosoftScopes helper and default user URL in microsoft.go
echo
echo "=== internal/providers/microsoft.go (Entire file) ==="
sed -n '1,200p' internal/providers/microsoft.goLength of output: 7606
Microsoft OAuth: Add scope parsing in config→OAuthConfig conversion
The providers layer already handles defaulting for auth, token, and user URLs (falls back to common v2.0 endpoints and Graph “/me” if unset) and will only invoke MicrosoftScopes() when the slice is empty. However, I did not find any code that converts the raw Config.MicrosoftScopes (a comma-separated string) into the []string used by OAuthConfig.MicrosoftScopes.
Please update your config-loading or main setup (where you build the types.OAuthConfig from types.Config) to:
- Split
Config.MicrosoftScopeson commas (e.g.strings.Split(raw, ",")) - Trim whitespace from each element
- Assign that slice to
OAuthConfig.MicrosoftScopes
With that in place, custom scopes will be respected and the built-in fallback only kicks in when no scopes are provided.
🤖 Prompt for AI Agents
In internal/types/config.go around lines 71 to 89, the MicrosoftScopes field is
defined as a []string but the raw config likely provides it as a comma-separated
string. To fix this, locate the code where you convert the raw Config into
OAuthConfig and add logic to split the raw MicrosoftScopes string by commas,
trim whitespace from each resulting element, and assign the cleaned slice to
OAuthConfig.MicrosoftScopes. This ensures custom scopes are properly parsed and
used instead of relying solely on the default fallback.
|
Hello! Is there any way to test this? Because I was originally planning to add Microsoft OAuth but I discovered I need Entra ID which is some business feature or something that's why I avoided Microsoft and let people manually configure it. Also I don't believe we need the custom URLs or scopes. Usually with built-in OAuth providers I use the defaults to ship an optimal config. |
|
@steveiliop56 I tested it and it worked. Registered a new app in Entra, create a new auth token, and add these bad boys to the And you're right, scopes are strictly needed - the URL's are needed for the authentication flow to work. Here's a video of it working. Screen.Recording.2025-08-09.at.20.57.43.mp4Logs: |
|
It's not about not believing you if it works or not, of course I do believe you lol. My issue is that in my testing "flow" I test every function manually and if cannot setup entra myself, I cannot test the Microsoft OAuth function. Is there any way to use entra myself without needing any business plans or similar? |
|
Not that I know of. Pretty sure it's an enterprise feature. |
|
Yeah that's the reason I avoided adding it. Does entra work with the generic OAuth? |
|
I'll have to do some testing first to see if various apps work with generic Auth. |
|
Entra does work with the generic provider! My setup is:
One big issue I was running into is that, for my use case, using the generic provider, it doesn't seem that there is any way to get the groups associated with users for use in access control. Based on my understanding (and reading Microsoft's docs), it seems that if using the I don't believe Tinyauth does anything like this at all currently (Although feel free to correct me if I'm wrong). If it were to be done, I do think it would require a significant change to the whole OAuth flow since I don't think we'd have to care about the "USER_URL" anymore and could get all user information from the ID token itself. Regarding the contents of the PR, I am not sure if there is also a means to get the users' "groups" provided to https://graph.microsoft.com/v1.0/me, but that would be very very nice... 🙏 |
|
@Fatiz |
|
Guys not sure if I should merge this because I cannot test any changes in the future. Not old enough to have a debit card so I can't sign up for the free version of azure. Although I will see what I can do. |
|
@steveiliop56 If anyone wants to use the features I built, they can always use my fork: https://github.com/need4swede/tinyauth |
|
This can be accomplished via the generic OAuth provider, as well. I've implemented it that way, and the flow works fine in all use cases. @steveiliop56, I would be happy to set you up with a tenant to test in if you are interested. |
|
Sure why not. By the way I made some tiny changes to the codebase so probably some refactoring will have to take place in the pull request ; ) |
|
Hi @Fatiz , I was testing your method of using the generic OAuth provider with Entra ID and discovered a potential security vulnerability when using the email whitelist (OAUTH_WHITELIST).
I haven't tested the code in this pull request but looking at the code. I think it might suffer the same issue. log.Debug().Msg("Parsed user from microsoft")
// Prefer mail, fallback to UserPrincipalName
email := userInfo.Mail
if email == "" {
email = userInfo.UserPrincipalName
}
user.PreferredUsername = strings.Split(email, "@")[0]
user.Name = userInfo.DisplayName
user.Email = emailThis behavior is documented by Microsoft here, confirming that email claims in Entra ID are unverified. @steveiliop56
This issue might exist in others if relying on the email claim. (I think google and github oauth should be safe but probably should still check the email_verified claim.) |
|
Hello @jdw1023, Thank you for letting me know. I believe this issue is specific to Microsoft OAuth so that's where security precautions must be taken. In any way the pull request in its current state cannot be merged due to the changes in the main codebase. @need4swede would you like to rework on it? |
|
Yeah I think I'm going to wait for v4 before I submit any additional changes. Feel free to close the PR for now and I can revisit this addition with the new major release |
|
Alright, closing this and #306. Feel free to reopen the pull requests after v4. Hopefully the changes in the codebase will make development much easier. |


Introduces Microsoft as a supported OAuth provider across backend and frontend. Adds Microsoft-specific config options, provider logic, user info retrieval, and UI icon/button for login. Updates configuration, provider initialization, and provider selection logic to include Microsoft.
Summary by CodeRabbit
New Features
Chores