# 使用 Microsoft / Entra ID 登录：OAuth 2.0 接入指南（Golang）

本文介绍通过 **Microsoft 身份平台（v2.0）** 使用个人或工作/学校账号登录：在 **Microsoft Entra（原 Azure AD）** 注册应用，走授权码流程，再用 **Microsoft Graph** 读取用户资料。与具体业务系统无关。

## 1. 协议与端点

v2.0 形式（`{tenant}` 可为 `common`、组织租户 ID、`consumers` 等，依你的「支持的帐户类型」选择）：

| 步骤 | URL |
|------|-----|
| 授权 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` |
| 换 token | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` |
| 当前用户 | `GET https://graph.microsoft.com/v1.0/me`，`Authorization: Bearer {token}` |

Go 中手动拼 `oauth2.Endpoint`：

```go
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
	if tenant == "" {
		tenant = "common"
	}
	base := "https://login.microsoftonline.com/" + tenant
	return oauth2.Endpoint{
		AuthURL:  base + "/oauth2/v2.0/authorize",
		TokenURL: base + "/oauth2/v2.0/token",
	}
}
```

Scope 至少包含：`openid`、`profile`、`email` 与 `https://graph.microsoft.com/User.Read`（读 `/me`）。

官方文档：[Microsoft identity platform and OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)

## 2. 在 Entra 注册应用

1. [Entra 管理中心](https://entra.microsoft.com/) → **应用注册** → **新注册**。  
2. **重定向 URI** 选 **Web**，填你的回调，例如：  
   `https://你的服务/oauth/microsoft/callback`  
3. **证书和密码** → 新建客户端机密；记录 **应用程序(客户端) ID** 与机密。  
4. **API 权限**：确保可登录并调用 Graph（`User.Read` 等，与 scope 一致）。

## 3. Golang 最小示例

```go
package main

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"sync"
	"time"

	"golang.org/x/oauth2"
)

func main() {
	tenant := getenv("MS_TENANT", "common")
	redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/microsoft/callback")

	cfg := &oauth2.Config{
		ClientID:     os.Getenv("MS_CLIENT_ID"),
		ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
		RedirectURL:  redirectURL,
		Scopes: []string{
			"openid", "profile", "email",
			"https://graph.microsoft.com/User.Read",
		},
		Endpoint: microsoftV2Endpoint(tenant),
	}
	if cfg.ClientID == "" || cfg.ClientSecret == "" {
		log.Fatal("set MS_CLIENT_ID and MS_CLIENT_SECRET")
	}

	states := newStateStore()

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		b := make([]byte, 16)
		_, _ = rand.Read(b)
		state := base64.RawURLEncoding.EncodeToString(b)
		states.Put(state, time.Now().Add(10*time.Minute))
		http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound)
	})

	http.HandleFunc("/oauth/microsoft/callback", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("error") != "" {
			http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
			return
		}
		if !states.Consume(r.URL.Query().Get("state")) {
			http.Error(w, "invalid state", http.StatusBadRequest)
			return
		}
		ctx := r.Context()
		tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
		if err != nil {
			http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest)
			return
		}
		u, err := fetchGraphMe(ctx, tok.AccessToken)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadGateway)
			return
		}
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(u)
	})

	log.Println("Entra redirect URI must match:", redirectURL)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

type graphMe struct {
	ID                string `json:"id"`
	Mail              string `json:"mail"`
	UserPrincipalName string `json:"userPrincipalName"`
	DisplayName       string `json:"displayName"`
}

func fetchGraphMe(ctx context.Context, access string) (*graphMe, error) {
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://graph.microsoft.com/v1.0/me", nil)
	req.Header.Set("Authorization", "Bearer "+access)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	b, _ := io.ReadAll(res.Body)
	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("graph: %d %s", res.StatusCode, b)
	}
	var u graphMe
	if err := json.Unmarshal(b, &u); err != nil {
		return nil, err
	}
	return &u, nil
}

func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
	if tenant == "" {
		tenant = "common"
	}
	base := "https://login.microsoftonline.com/" + tenant
	return oauth2.Endpoint{
		AuthURL:  base + "/oauth2/v2.0/authorize",
		TokenURL: base + "/oauth2/v2.0/token",
	}
}

type stateStore struct {
	mu sync.Mutex
	m  map[string]time.Time
}

func newStateStore() *stateStore { return &stateStore{m: make(map[string]time.Time)} }
func (s *stateStore) Put(k string, exp time.Time) {
	s.mu.Lock()
	s.m[k] = exp
	s.mu.Unlock()
}
func (s *stateStore) Consume(k string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.m[k]
	if !ok || time.Now().After(t) {
		return false
	}
	delete(s.m, k)
	return true
}
func getenv(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}
```

## 4. 租户 `common` 与单租户

- **`common`**：适合「多组织 + 个人账户」类应用；与 Entra 注册时「支持的帐户类型」需一致。  
- **单一组织**：将 `MS_TENANT` 设为**目录租户 ID**，并在应用注册中选择仅本组织，减少误登范围。

## 5. 常见错误

- **换 token 失败**：`redirect_uri` 与注册应用时不一致；或 Secret 错误。  
- **Graph 403**：未授予 `User.Read` 或 token 中无对应 scope。

## 6. 安全建议

- 使用返回的 **用户 `id`（GUID）** 作为外部账号主键。  
- `state` 防重放；Secret 仅存服务端。
