mirror of https://github.com/grafana/grafana
Auth: Azure AD OAuth (#20030)
* Implement Azure AD oauth * Use go-jose and cleanup * Update go-jose in go.mod * cleanup * Add unit tests * Fix scopes * Add documentation page * Improve documentation * Convert extract_role into function. * Do not use upn and replace unique_name with preferred_username * Configure login button * Use official microsoft icon and color from branding guideline. * Add Azure AD config section in sample.ini.pull/22173/head
parent
ceca067d4f
commit
ff6a082e23
@ -0,0 +1,109 @@ |
||||
+++ |
||||
title = "Azure AD OAuth2 Authentication" |
||||
description = "Grafana OAuthentication Guide " |
||||
keywords = ["grafana", "configuration", "documentation", "oauth"] |
||||
type = "docs" |
||||
[menu.docs] |
||||
name = "Azure AD" |
||||
identifier = "azuread_oauth2" |
||||
parent = "authentication" |
||||
weight = 3 |
||||
+++ |
||||
|
||||
# Azure AD OAuth2 Authentication |
||||
|
||||
The Azure AD authentication provides the possibility to use an Azure Active Directory tenant as an identity provider for Grafana. |
||||
|
||||
By using Azure AD Application Roles it is also possible to assign Users and Groups to Grafana roles from the Azure Portal. |
||||
|
||||
To enable the Azure AD OAuth2 you must register your application with Azure AD. |
||||
|
||||
# Create Azure AD application |
||||
|
||||
1. Log in to [Azure Portal](https://portal.azure.com) and click **Azure Active Directory** in the side menu. |
||||
|
||||
1. Click **App Registrations** and add a new application registration: |
||||
- Name: Grafana |
||||
- Application type: Web app / API |
||||
- Sign-on URL: `https://<grafana domain>/login/azuread` |
||||
|
||||
1. Click the name of the new application to open the application details page. |
||||
|
||||
1. Click **Endpoints**. |
||||
- Note down the **OAuth 2.0 authorization endpoint (v2)**, this will be the auth url. |
||||
- Note down the **OAuth 2.0 token endpoint (v2)**, this will be the token url. |
||||
|
||||
1. Close the Endpoints page to come back to the application details page. |
||||
|
||||
1. Note down the "Application ID", this will be the OAuth client id. |
||||
|
||||
1. Click **Certificates & secrets** and add a new entry under Client secrets. |
||||
- Description: Grafana OAuth |
||||
- Expires: Never |
||||
|
||||
1. Click **Add** then copy the key value, this will be the OAuth client secret. |
||||
|
||||
1. Click **Manifest**. |
||||
- Add definitions for the required Application Roles for Grafana (Viewer, Editor, Admin). Without this configuration all users will be assigned to the Viewer role. |
||||
- Every role has to have a unique id. On Linux this can be created with `uuidgen` for instance. |
||||
|
||||
```json |
||||
"appRoles": [ |
||||
{ |
||||
"allowedMemberTypes": [ |
||||
"User" |
||||
], |
||||
"description": "Grafana admin Users", |
||||
"displayName": "Grafana Admin", |
||||
"id": "SOME_UNIQUE_ID", |
||||
"isEnabled": true, |
||||
"lang": null, |
||||
"origin": "Application", |
||||
"value": "Admin" |
||||
}, |
||||
{ |
||||
"allowedMemberTypes": [ |
||||
"User" |
||||
], |
||||
"description": "Grafana read only Users", |
||||
"displayName": "Grafana Viewer", |
||||
"id": "SOME_UNIQUE_ID", |
||||
"isEnabled": true, |
||||
"lang": null, |
||||
"origin": "Application", |
||||
"value": "Viewer" |
||||
}, |
||||
{ |
||||
"allowedMemberTypes": [ |
||||
"User" |
||||
], |
||||
"description": "Grafana Editor Users", |
||||
"displayName": "Grafana Editor", |
||||
"id": "SOME_UNIQUE_ID", |
||||
"isEnabled": true, |
||||
"lang": null, |
||||
"origin": "Application", |
||||
"value": "Editor" |
||||
} |
||||
], |
||||
``` |
||||
|
||||
1. Click Overview and then on **Managed application in local directory** to show the Enterprise Application details. |
||||
|
||||
1. Click on **Users and groups** and add Users/Groups to the Grafana roles by using **Add User**. |
||||
|
||||
1. Add the following to the [Grafana configuration file]({{< relref "../installation/configuration.md#config-file-locations" >}}): |
||||
|
||||
```ini |
||||
[auth.azuread] |
||||
name = Azure AD |
||||
enabled = true |
||||
allow_sign_up = true |
||||
client_id = APPLICATION_ID |
||||
client_secret = CLIENT_SECRET |
||||
scopes = openid email profile |
||||
auth_url = https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/authorize |
||||
token_url = https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token |
||||
``` |
||||
|
||||
> Note: Ensure that the [root_url]({{< relref "../installation/configuration/#root-url" >}}) in Grafana is set in your Azure Application Reply URLs (App -> Settings -> Reply URLs) |
||||
@ -0,0 +1,111 @@ |
||||
package social |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
"github.com/grafana/grafana/pkg/models" |
||||
|
||||
"golang.org/x/oauth2" |
||||
"gopkg.in/square/go-jose.v2/jwt" |
||||
) |
||||
|
||||
type SocialAzureAD struct { |
||||
*SocialBase |
||||
allowedDomains []string |
||||
allowSignup bool |
||||
} |
||||
|
||||
type azureClaims struct { |
||||
Email string `json:"email"` |
||||
PreferredUsername string `json:"preferred_username"` |
||||
Roles []string `json:"roles"` |
||||
Name string `json:"name"` |
||||
ID string `json:"oid"` |
||||
} |
||||
|
||||
func (s *SocialAzureAD) Type() int { |
||||
return int(models.AZUREAD) |
||||
} |
||||
|
||||
func (s *SocialAzureAD) IsEmailAllowed(email string) bool { |
||||
return isEmailAllowed(email, s.allowedDomains) |
||||
} |
||||
|
||||
func (s *SocialAzureAD) IsSignupAllowed() bool { |
||||
return s.allowSignup |
||||
} |
||||
|
||||
func (s *SocialAzureAD) UserInfo(_ *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { |
||||
idToken := token.Extra("id_token") |
||||
if idToken == nil { |
||||
return nil, fmt.Errorf("No id_token found") |
||||
} |
||||
|
||||
parsedToken, err := jwt.ParseSigned(idToken.(string)) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("Error parsing id token") |
||||
} |
||||
|
||||
var claims azureClaims |
||||
if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil { |
||||
return nil, fmt.Errorf("Error getting claims from id token") |
||||
} |
||||
|
||||
email := extractEmail(claims) |
||||
|
||||
if email == "" { |
||||
return nil, errors.New("Error getting user info: No email found in access token") |
||||
} |
||||
|
||||
role := extractRole(claims) |
||||
|
||||
return &BasicUserInfo{ |
||||
Id: claims.ID, |
||||
Name: claims.Name, |
||||
Email: email, |
||||
Login: email, |
||||
Role: string(role), |
||||
}, nil |
||||
} |
||||
|
||||
func extractEmail(claims azureClaims) string { |
||||
if claims.Email == "" { |
||||
if claims.PreferredUsername != "" { |
||||
return claims.PreferredUsername |
||||
} |
||||
} |
||||
|
||||
return claims.Email |
||||
} |
||||
|
||||
func extractRole(claims azureClaims) models.RoleType { |
||||
if len(claims.Roles) == 0 { |
||||
return models.ROLE_VIEWER |
||||
} |
||||
|
||||
roleOrder := []models.RoleType{ |
||||
models.ROLE_ADMIN, |
||||
models.ROLE_EDITOR, |
||||
models.ROLE_VIEWER, |
||||
} |
||||
|
||||
for _, role := range roleOrder { |
||||
if found := hasRole(claims.Roles, role); found { |
||||
return role |
||||
} |
||||
} |
||||
|
||||
return models.ROLE_VIEWER |
||||
} |
||||
|
||||
func hasRole(roles []string, role models.RoleType) bool { |
||||
for _, item := range roles { |
||||
if strings.EqualFold(item, string(role)) { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
@ -0,0 +1,234 @@ |
||||
package social |
||||
|
||||
import ( |
||||
"golang.org/x/oauth2" |
||||
"gopkg.in/square/go-jose.v2" |
||||
"gopkg.in/square/go-jose.v2/jwt" |
||||
"net/http" |
||||
"reflect" |
||||
"testing" |
||||
"time" |
||||
) |
||||
|
||||
func TestSocialAzureAD_UserInfo(t *testing.T) { |
||||
type fields struct { |
||||
SocialBase *SocialBase |
||||
allowedDomains []string |
||||
allowSignup bool |
||||
} |
||||
type args struct { |
||||
client *http.Client |
||||
} |
||||
|
||||
tests := []struct { |
||||
name string |
||||
fields fields |
||||
claims *azureClaims |
||||
args args |
||||
want *BasicUserInfo |
||||
wantErr bool |
||||
}{ |
||||
{ |
||||
name: "Email in email claim", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Viewer", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "No email", |
||||
claims: &azureClaims{ |
||||
Email: "", |
||||
PreferredUsername: "", |
||||
Roles: []string{}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "No id token", |
||||
claims: nil, |
||||
want: nil, |
||||
wantErr: true, |
||||
}, |
||||
{ |
||||
name: "Email in preferred_username claim", |
||||
claims: &azureClaims{ |
||||
Email: "", |
||||
PreferredUsername: "me@example.com", |
||||
Roles: []string{}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Viewer", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Admin role", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{"Admin"}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Admin", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Lowercase Admin role", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{"admin"}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Admin", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Only other roles", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{"AppAdmin"}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Viewer", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
|
||||
{ |
||||
name: "Editor role", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{"Editor"}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Editor", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
{ |
||||
name: "Admin and Editor roles in claim", |
||||
claims: &azureClaims{ |
||||
Email: "me@example.com", |
||||
PreferredUsername: "", |
||||
Roles: []string{"Admin", "Editor"}, |
||||
Name: "My Name", |
||||
ID: "1234", |
||||
}, |
||||
want: &BasicUserInfo{ |
||||
Id: "1234", |
||||
Name: "My Name", |
||||
Email: "me@example.com", |
||||
Login: "me@example.com", |
||||
Company: "", |
||||
Role: "Admin", |
||||
Groups: nil, |
||||
}, |
||||
}, |
||||
} |
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
s := &SocialAzureAD{ |
||||
SocialBase: tt.fields.SocialBase, |
||||
allowedDomains: tt.fields.allowedDomains, |
||||
allowSignup: tt.fields.allowSignup, |
||||
} |
||||
|
||||
key := []byte("secret") |
||||
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) |
||||
if err != nil { |
||||
panic(err) |
||||
} |
||||
|
||||
cl := jwt.Claims{ |
||||
Subject: "subject", |
||||
Issuer: "issuer", |
||||
NotBefore: jwt.NewNumericDate(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)), |
||||
Audience: jwt.Audience{"leela", "fry"}, |
||||
} |
||||
|
||||
var raw string |
||||
if tt.claims != nil { |
||||
raw, err = jwt.Signed(sig).Claims(cl).Claims(tt.claims).CompactSerialize() |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
} else { |
||||
raw, err = jwt.Signed(sig).Claims(cl).CompactSerialize() |
||||
if err != nil { |
||||
t.Error(err) |
||||
} |
||||
|
||||
} |
||||
|
||||
token := &oauth2.Token{} |
||||
if tt.claims != nil { |
||||
token = token.WithExtra(map[string]interface{}{"id_token": raw}) |
||||
} |
||||
|
||||
got, err := s.UserInfo(tt.args.client, token) |
||||
if (err != nil) != tt.wantErr { |
||||
t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) |
||||
return |
||||
} |
||||
if !reflect.DeepEqual(got, tt.want) { |
||||
t.Errorf("UserInfo() got = %v, want %v", got, tt.want) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
After Width: | Height: | Size: 343 B |
@ -0,0 +1,334 @@ |
||||
/*- |
||||
* Copyright 2016 Zbigniew Mandziejewicz |
||||
* Copyright 2016 Square, Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package jwt |
||||
|
||||
import ( |
||||
"bytes" |
||||
"reflect" |
||||
|
||||
"gopkg.in/square/go-jose.v2/json" |
||||
|
||||
"gopkg.in/square/go-jose.v2" |
||||
) |
||||
|
||||
// Builder is a utility for making JSON Web Tokens. Calls can be chained, and
|
||||
// errors are accumulated until the final call to CompactSerialize/FullSerialize.
|
||||
type Builder interface { |
||||
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
|
||||
// into single JSON object. If you are passing private claims, make sure to set
|
||||
// struct field tags to specify the name for the JSON key to be used when
|
||||
// serializing.
|
||||
Claims(i interface{}) Builder |
||||
// Token builds a JSONWebToken from provided data.
|
||||
Token() (*JSONWebToken, error) |
||||
// FullSerialize serializes a token using the full serialization format.
|
||||
FullSerialize() (string, error) |
||||
// CompactSerialize serializes a token using the compact serialization format.
|
||||
CompactSerialize() (string, error) |
||||
} |
||||
|
||||
// NestedBuilder is a utility for making Signed-Then-Encrypted JSON Web Tokens.
|
||||
// Calls can be chained, and errors are accumulated until final call to
|
||||
// CompactSerialize/FullSerialize.
|
||||
type NestedBuilder interface { |
||||
// Claims encodes claims into JWE/JWS form. Multiple calls will merge claims
|
||||
// into single JSON object. If you are passing private claims, make sure to set
|
||||
// struct field tags to specify the name for the JSON key to be used when
|
||||
// serializing.
|
||||
Claims(i interface{}) NestedBuilder |
||||
// Token builds a NestedJSONWebToken from provided data.
|
||||
Token() (*NestedJSONWebToken, error) |
||||
// FullSerialize serializes a token using the full serialization format.
|
||||
FullSerialize() (string, error) |
||||
// CompactSerialize serializes a token using the compact serialization format.
|
||||
CompactSerialize() (string, error) |
||||
} |
||||
|
||||
type builder struct { |
||||
payload map[string]interface{} |
||||
err error |
||||
} |
||||
|
||||
type signedBuilder struct { |
||||
builder |
||||
sig jose.Signer |
||||
} |
||||
|
||||
type encryptedBuilder struct { |
||||
builder |
||||
enc jose.Encrypter |
||||
} |
||||
|
||||
type nestedBuilder struct { |
||||
builder |
||||
sig jose.Signer |
||||
enc jose.Encrypter |
||||
} |
||||
|
||||
// Signed creates builder for signed tokens.
|
||||
func Signed(sig jose.Signer) Builder { |
||||
return &signedBuilder{ |
||||
sig: sig, |
||||
} |
||||
} |
||||
|
||||
// Encrypted creates builder for encrypted tokens.
|
||||
func Encrypted(enc jose.Encrypter) Builder { |
||||
return &encryptedBuilder{ |
||||
enc: enc, |
||||
} |
||||
} |
||||
|
||||
// SignedAndEncrypted creates builder for signed-then-encrypted tokens.
|
||||
// ErrInvalidContentType will be returned if encrypter doesn't have JWT content type.
|
||||
func SignedAndEncrypted(sig jose.Signer, enc jose.Encrypter) NestedBuilder { |
||||
if contentType, _ := enc.Options().ExtraHeaders[jose.HeaderContentType].(jose.ContentType); contentType != "JWT" { |
||||
return &nestedBuilder{ |
||||
builder: builder{ |
||||
err: ErrInvalidContentType, |
||||
}, |
||||
} |
||||
} |
||||
return &nestedBuilder{ |
||||
sig: sig, |
||||
enc: enc, |
||||
} |
||||
} |
||||
|
||||
func (b builder) claims(i interface{}) builder { |
||||
if b.err != nil { |
||||
return b |
||||
} |
||||
|
||||
m, ok := i.(map[string]interface{}) |
||||
switch { |
||||
case ok: |
||||
return b.merge(m) |
||||
case reflect.Indirect(reflect.ValueOf(i)).Kind() == reflect.Struct: |
||||
m, err := normalize(i) |
||||
if err != nil { |
||||
return builder{ |
||||
err: err, |
||||
} |
||||
} |
||||
return b.merge(m) |
||||
default: |
||||
return builder{ |
||||
err: ErrInvalidClaims, |
||||
} |
||||
} |
||||
} |
||||
|
||||
func normalize(i interface{}) (map[string]interface{}, error) { |
||||
m := make(map[string]interface{}) |
||||
|
||||
raw, err := json.Marshal(i) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
d := json.NewDecoder(bytes.NewReader(raw)) |
||||
d.UseNumber() |
||||
|
||||
if err := d.Decode(&m); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return m, nil |
||||
} |
||||
|
||||
func (b *builder) merge(m map[string]interface{}) builder { |
||||
p := make(map[string]interface{}) |
||||
for k, v := range b.payload { |
||||
p[k] = v |
||||
} |
||||
for k, v := range m { |
||||
p[k] = v |
||||
} |
||||
|
||||
return builder{ |
||||
payload: p, |
||||
} |
||||
} |
||||
|
||||
func (b *builder) token(p func(interface{}) ([]byte, error), h []jose.Header) (*JSONWebToken, error) { |
||||
return &JSONWebToken{ |
||||
payload: p, |
||||
Headers: h, |
||||
}, nil |
||||
} |
||||
|
||||
func (b *signedBuilder) Claims(i interface{}) Builder { |
||||
return &signedBuilder{ |
||||
builder: b.builder.claims(i), |
||||
sig: b.sig, |
||||
} |
||||
} |
||||
|
||||
func (b *signedBuilder) Token() (*JSONWebToken, error) { |
||||
sig, err := b.sign() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
h := make([]jose.Header, len(sig.Signatures)) |
||||
for i, v := range sig.Signatures { |
||||
h[i] = v.Header |
||||
} |
||||
|
||||
return b.builder.token(sig.Verify, h) |
||||
} |
||||
|
||||
func (b *signedBuilder) CompactSerialize() (string, error) { |
||||
sig, err := b.sign() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return sig.CompactSerialize() |
||||
} |
||||
|
||||
func (b *signedBuilder) FullSerialize() (string, error) { |
||||
sig, err := b.sign() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return sig.FullSerialize(), nil |
||||
} |
||||
|
||||
func (b *signedBuilder) sign() (*jose.JSONWebSignature, error) { |
||||
if b.err != nil { |
||||
return nil, b.err |
||||
} |
||||
|
||||
p, err := json.Marshal(b.payload) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return b.sig.Sign(p) |
||||
} |
||||
|
||||
func (b *encryptedBuilder) Claims(i interface{}) Builder { |
||||
return &encryptedBuilder{ |
||||
builder: b.builder.claims(i), |
||||
enc: b.enc, |
||||
} |
||||
} |
||||
|
||||
func (b *encryptedBuilder) CompactSerialize() (string, error) { |
||||
enc, err := b.encrypt() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return enc.CompactSerialize() |
||||
} |
||||
|
||||
func (b *encryptedBuilder) FullSerialize() (string, error) { |
||||
enc, err := b.encrypt() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return enc.FullSerialize(), nil |
||||
} |
||||
|
||||
func (b *encryptedBuilder) Token() (*JSONWebToken, error) { |
||||
enc, err := b.encrypt() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return b.builder.token(enc.Decrypt, []jose.Header{enc.Header}) |
||||
} |
||||
|
||||
func (b *encryptedBuilder) encrypt() (*jose.JSONWebEncryption, error) { |
||||
if b.err != nil { |
||||
return nil, b.err |
||||
} |
||||
|
||||
p, err := json.Marshal(b.payload) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return b.enc.Encrypt(p) |
||||
} |
||||
|
||||
func (b *nestedBuilder) Claims(i interface{}) NestedBuilder { |
||||
return &nestedBuilder{ |
||||
builder: b.builder.claims(i), |
||||
sig: b.sig, |
||||
enc: b.enc, |
||||
} |
||||
} |
||||
|
||||
func (b *nestedBuilder) Token() (*NestedJSONWebToken, error) { |
||||
enc, err := b.signAndEncrypt() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &NestedJSONWebToken{ |
||||
enc: enc, |
||||
Headers: []jose.Header{enc.Header}, |
||||
}, nil |
||||
} |
||||
|
||||
func (b *nestedBuilder) CompactSerialize() (string, error) { |
||||
enc, err := b.signAndEncrypt() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return enc.CompactSerialize() |
||||
} |
||||
|
||||
func (b *nestedBuilder) FullSerialize() (string, error) { |
||||
enc, err := b.signAndEncrypt() |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return enc.FullSerialize(), nil |
||||
} |
||||
|
||||
func (b *nestedBuilder) signAndEncrypt() (*jose.JSONWebEncryption, error) { |
||||
if b.err != nil { |
||||
return nil, b.err |
||||
} |
||||
|
||||
p, err := json.Marshal(b.payload) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
sig, err := b.sig.Sign(p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
p2, err := sig.CompactSerialize() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return b.enc.Encrypt([]byte(p2)) |
||||
} |
||||
@ -0,0 +1,120 @@ |
||||
/*- |
||||
* Copyright 2016 Zbigniew Mandziejewicz |
||||
* Copyright 2016 Square, Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package jwt |
||||
|
||||
import ( |
||||
"strconv" |
||||
"time" |
||||
|
||||
"gopkg.in/square/go-jose.v2/json" |
||||
) |
||||
|
||||
// Claims represents public claim values (as specified in RFC 7519).
|
||||
type Claims struct { |
||||
Issuer string `json:"iss,omitempty"` |
||||
Subject string `json:"sub,omitempty"` |
||||
Audience Audience `json:"aud,omitempty"` |
||||
Expiry *NumericDate `json:"exp,omitempty"` |
||||
NotBefore *NumericDate `json:"nbf,omitempty"` |
||||
IssuedAt *NumericDate `json:"iat,omitempty"` |
||||
ID string `json:"jti,omitempty"` |
||||
} |
||||
|
||||
// NumericDate represents date and time as the number of seconds since the
|
||||
// epoch, including leap seconds. Non-integer values can be represented
|
||||
// in the serialized format, but we round to the nearest second.
|
||||
type NumericDate int64 |
||||
|
||||
// NewNumericDate constructs NumericDate from time.Time value.
|
||||
func NewNumericDate(t time.Time) *NumericDate { |
||||
if t.IsZero() { |
||||
return nil |
||||
} |
||||
|
||||
// While RFC 7519 technically states that NumericDate values may be
|
||||
// non-integer values, we don't bother serializing timestamps in
|
||||
// claims with sub-second accurancy and just round to the nearest
|
||||
// second instead. Not convined sub-second accuracy is useful here.
|
||||
out := NumericDate(t.Unix()) |
||||
return &out |
||||
} |
||||
|
||||
// MarshalJSON serializes the given NumericDate into its JSON representation.
|
||||
func (n NumericDate) MarshalJSON() ([]byte, error) { |
||||
return []byte(strconv.FormatInt(int64(n), 10)), nil |
||||
} |
||||
|
||||
// UnmarshalJSON reads a date from its JSON representation.
|
||||
func (n *NumericDate) UnmarshalJSON(b []byte) error { |
||||
s := string(b) |
||||
|
||||
f, err := strconv.ParseFloat(s, 64) |
||||
if err != nil { |
||||
return ErrUnmarshalNumericDate |
||||
} |
||||
|
||||
*n = NumericDate(f) |
||||
return nil |
||||
} |
||||
|
||||
// Time returns time.Time representation of NumericDate.
|
||||
func (n *NumericDate) Time() time.Time { |
||||
if n == nil { |
||||
return time.Time{} |
||||
} |
||||
return time.Unix(int64(*n), 0) |
||||
} |
||||
|
||||
// Audience represents the recipents that the token is intended for.
|
||||
type Audience []string |
||||
|
||||
// UnmarshalJSON reads an audience from its JSON representation.
|
||||
func (s *Audience) UnmarshalJSON(b []byte) error { |
||||
var v interface{} |
||||
if err := json.Unmarshal(b, &v); err != nil { |
||||
return err |
||||
} |
||||
|
||||
switch v := v.(type) { |
||||
case string: |
||||
*s = []string{v} |
||||
case []interface{}: |
||||
a := make([]string, len(v)) |
||||
for i, e := range v { |
||||
s, ok := e.(string) |
||||
if !ok { |
||||
return ErrUnmarshalAudience |
||||
} |
||||
a[i] = s |
||||
} |
||||
*s = a |
||||
default: |
||||
return ErrUnmarshalAudience |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (s Audience) Contains(v string) bool { |
||||
for _, a := range s { |
||||
if a == v { |
||||
return true |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
@ -0,0 +1,22 @@ |
||||
/*- |
||||
* Copyright 2017 Square Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
/* |
||||
|
||||
Package jwt provides an implementation of the JSON Web Token standard. |
||||
|
||||
*/ |
||||
package jwt |
||||
@ -0,0 +1,53 @@ |
||||
/*- |
||||
* Copyright 2016 Zbigniew Mandziejewicz |
||||
* Copyright 2016 Square, Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package jwt |
||||
|
||||
import "errors" |
||||
|
||||
// ErrUnmarshalAudience indicates that aud claim could not be unmarshalled.
|
||||
var ErrUnmarshalAudience = errors.New("square/go-jose/jwt: expected string or array value to unmarshal to Audience") |
||||
|
||||
// ErrUnmarshalNumericDate indicates that JWT NumericDate could not be unmarshalled.
|
||||
var ErrUnmarshalNumericDate = errors.New("square/go-jose/jwt: expected number value to unmarshal NumericDate") |
||||
|
||||
// ErrInvalidClaims indicates that given claims have invalid type.
|
||||
var ErrInvalidClaims = errors.New("square/go-jose/jwt: expected claims to be value convertible into JSON object") |
||||
|
||||
// ErrInvalidIssuer indicates invalid iss claim.
|
||||
var ErrInvalidIssuer = errors.New("square/go-jose/jwt: validation failed, invalid issuer claim (iss)") |
||||
|
||||
// ErrInvalidSubject indicates invalid sub claim.
|
||||
var ErrInvalidSubject = errors.New("square/go-jose/jwt: validation failed, invalid subject claim (sub)") |
||||
|
||||
// ErrInvalidAudience indicated invalid aud claim.
|
||||
var ErrInvalidAudience = errors.New("square/go-jose/jwt: validation failed, invalid audience claim (aud)") |
||||
|
||||
// ErrInvalidID indicates invalid jti claim.
|
||||
var ErrInvalidID = errors.New("square/go-jose/jwt: validation failed, invalid ID claim (jti)") |
||||
|
||||
// ErrNotValidYet indicates that token is used before time indicated in nbf claim.
|
||||
var ErrNotValidYet = errors.New("square/go-jose/jwt: validation failed, token not valid yet (nbf)") |
||||
|
||||
// ErrExpired indicates that token is used after expiry time indicated in exp claim.
|
||||
var ErrExpired = errors.New("square/go-jose/jwt: validation failed, token is expired (exp)") |
||||
|
||||
// ErrIssuedInTheFuture indicates that the iat field is in the future.
|
||||
var ErrIssuedInTheFuture = errors.New("square/go-jose/jwt: validation field, token issued in the future (iat)") |
||||
|
||||
// ErrInvalidContentType indicates that token requires JWT cty header.
|
||||
var ErrInvalidContentType = errors.New("square/go-jose/jwt: expected content type to be JWT (cty header)") |
||||
@ -0,0 +1,163 @@ |
||||
/*- |
||||
* Copyright 2016 Zbigniew Mandziejewicz |
||||
* Copyright 2016 Square, Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package jwt |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
|
||||
jose "gopkg.in/square/go-jose.v2" |
||||
"gopkg.in/square/go-jose.v2/json" |
||||
) |
||||
|
||||
// JSONWebToken represents a JSON Web Token (as specified in RFC7519).
|
||||
type JSONWebToken struct { |
||||
payload func(k interface{}) ([]byte, error) |
||||
unverifiedPayload func() []byte |
||||
Headers []jose.Header |
||||
} |
||||
|
||||
type NestedJSONWebToken struct { |
||||
enc *jose.JSONWebEncryption |
||||
Headers []jose.Header |
||||
} |
||||
|
||||
// Claims deserializes a JSONWebToken into dest using the provided key.
|
||||
func (t *JSONWebToken) Claims(key interface{}, dest ...interface{}) error { |
||||
payloadKey := tryJWKS(t.Headers, key) |
||||
|
||||
b, err := t.payload(payloadKey) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, d := range dest { |
||||
if err := json.Unmarshal(b, d); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// UnsafeClaimsWithoutVerification deserializes the claims of a
|
||||
// JSONWebToken into the dests. For signed JWTs, the claims are not
|
||||
// verified. This function won't work for encrypted JWTs.
|
||||
func (t *JSONWebToken) UnsafeClaimsWithoutVerification(dest ...interface{}) error { |
||||
if t.unverifiedPayload == nil { |
||||
return fmt.Errorf("square/go-jose: Cannot get unverified claims") |
||||
} |
||||
claims := t.unverifiedPayload() |
||||
for _, d := range dest { |
||||
if err := json.Unmarshal(claims, d); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func (t *NestedJSONWebToken) Decrypt(decryptionKey interface{}) (*JSONWebToken, error) { |
||||
key := tryJWKS(t.Headers, decryptionKey) |
||||
|
||||
b, err := t.enc.Decrypt(key) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
sig, err := ParseSigned(string(b)) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return sig, nil |
||||
} |
||||
|
||||
// ParseSigned parses token from JWS form.
|
||||
func ParseSigned(s string) (*JSONWebToken, error) { |
||||
sig, err := jose.ParseSigned(s) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
headers := make([]jose.Header, len(sig.Signatures)) |
||||
for i, signature := range sig.Signatures { |
||||
headers[i] = signature.Header |
||||
} |
||||
|
||||
return &JSONWebToken{ |
||||
payload: sig.Verify, |
||||
unverifiedPayload: sig.UnsafePayloadWithoutVerification, |
||||
Headers: headers, |
||||
}, nil |
||||
} |
||||
|
||||
// ParseEncrypted parses token from JWE form.
|
||||
func ParseEncrypted(s string) (*JSONWebToken, error) { |
||||
enc, err := jose.ParseEncrypted(s) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &JSONWebToken{ |
||||
payload: enc.Decrypt, |
||||
Headers: []jose.Header{enc.Header}, |
||||
}, nil |
||||
} |
||||
|
||||
// ParseSignedAndEncrypted parses signed-then-encrypted token from JWE form.
|
||||
func ParseSignedAndEncrypted(s string) (*NestedJSONWebToken, error) { |
||||
enc, err := jose.ParseEncrypted(s) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
contentType, _ := enc.Header.ExtraHeaders[jose.HeaderContentType].(string) |
||||
if strings.ToUpper(contentType) != "JWT" { |
||||
return nil, ErrInvalidContentType |
||||
} |
||||
|
||||
return &NestedJSONWebToken{ |
||||
enc: enc, |
||||
Headers: []jose.Header{enc.Header}, |
||||
}, nil |
||||
} |
||||
|
||||
func tryJWKS(headers []jose.Header, key interface{}) interface{} { |
||||
jwks, ok := key.(*jose.JSONWebKeySet) |
||||
if !ok { |
||||
return key |
||||
} |
||||
|
||||
var kid string |
||||
for _, header := range headers { |
||||
if header.KeyID != "" { |
||||
kid = header.KeyID |
||||
break |
||||
} |
||||
} |
||||
|
||||
if kid == "" { |
||||
return key |
||||
} |
||||
|
||||
keys := jwks.Key(kid) |
||||
if len(keys) == 0 { |
||||
return key |
||||
} |
||||
|
||||
return keys[0].Key |
||||
} |
||||
@ -0,0 +1,114 @@ |
||||
/*- |
||||
* Copyright 2016 Zbigniew Mandziejewicz |
||||
* Copyright 2016 Square, Inc. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package jwt |
||||
|
||||
import "time" |
||||
|
||||
const ( |
||||
// DefaultLeeway defines the default leeway for matching NotBefore/Expiry claims.
|
||||
DefaultLeeway = 1.0 * time.Minute |
||||
) |
||||
|
||||
// Expected defines values used for protected claims validation.
|
||||
// If field has zero value then validation is skipped.
|
||||
type Expected struct { |
||||
// Issuer matches the "iss" claim exactly.
|
||||
Issuer string |
||||
// Subject matches the "sub" claim exactly.
|
||||
Subject string |
||||
// Audience matches the values in "aud" claim, regardless of their order.
|
||||
Audience Audience |
||||
// ID matches the "jti" claim exactly.
|
||||
ID string |
||||
// Time matches the "exp" and "nbf" claims with leeway.
|
||||
Time time.Time |
||||
} |
||||
|
||||
// WithTime copies expectations with new time.
|
||||
func (e Expected) WithTime(t time.Time) Expected { |
||||
e.Time = t |
||||
return e |
||||
} |
||||
|
||||
// Validate checks claims in a token against expected values.
|
||||
// A default leeway value of one minute is used to compare time values.
|
||||
//
|
||||
// The default leeway will cause the token to be deemed valid until one
|
||||
// minute after the expiration time. If you're a server application that
|
||||
// wants to give an extra minute to client tokens, use this
|
||||
// function. If you're a client application wondering if the server
|
||||
// will accept your token, use ValidateWithLeeway with a leeway <=0,
|
||||
// otherwise this function might make you think a token is valid when
|
||||
// it is not.
|
||||
func (c Claims) Validate(e Expected) error { |
||||
return c.ValidateWithLeeway(e, DefaultLeeway) |
||||
} |
||||
|
||||
// ValidateWithLeeway checks claims in a token against expected values. A
|
||||
// custom leeway may be specified for comparing time values. You may pass a
|
||||
// zero value to check time values with no leeway, but you should not that
|
||||
// numeric date values are rounded to the nearest second and sub-second
|
||||
// precision is not supported.
|
||||
//
|
||||
// The leeway gives some extra time to the token from the server's
|
||||
// point of view. That is, if the token is expired, ValidateWithLeeway
|
||||
// will still accept the token for 'leeway' amount of time. This fails
|
||||
// if you're using this function to check if a server will accept your
|
||||
// token, because it will think the token is valid even after it
|
||||
// expires. So if you're a client validating if the token is valid to
|
||||
// be submitted to a server, use leeway <=0, if you're a server
|
||||
// validation a token, use leeway >=0.
|
||||
func (c Claims) ValidateWithLeeway(e Expected, leeway time.Duration) error { |
||||
if e.Issuer != "" && e.Issuer != c.Issuer { |
||||
return ErrInvalidIssuer |
||||
} |
||||
|
||||
if e.Subject != "" && e.Subject != c.Subject { |
||||
return ErrInvalidSubject |
||||
} |
||||
|
||||
if e.ID != "" && e.ID != c.ID { |
||||
return ErrInvalidID |
||||
} |
||||
|
||||
if len(e.Audience) != 0 { |
||||
for _, v := range e.Audience { |
||||
if !c.Audience.Contains(v) { |
||||
return ErrInvalidAudience |
||||
} |
||||
} |
||||
} |
||||
|
||||
if !e.Time.IsZero() { |
||||
if c.NotBefore != nil && e.Time.Add(leeway).Before(c.NotBefore.Time()) { |
||||
return ErrNotValidYet |
||||
} |
||||
|
||||
if c.Expiry != nil && e.Time.Add(-leeway).After(c.Expiry.Time()) { |
||||
return ErrExpired |
||||
} |
||||
|
||||
// IssuedAt is optional but cannot be in the future. This is not required by the RFC, but
|
||||
// something is misconfigured if this happens and we should not trust it.
|
||||
if c.IssuedAt != nil && e.Time.Add(leeway).Before(c.IssuedAt.Time()) { |
||||
return ErrIssuedInTheFuture |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
Loading…
Reference in new issue