Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,36 @@ Once tokens have been obtained, `zat -no-server` will perform only archival duti
"oauth_redirect": "http://127.0.0.1.ip.es.io:8080/oauth/zoom"
}
```
* Google Meet support (optional)
* Meet recordings & transcripts already save to the host's Google Drive. `zat`
copies them into the mapped folder (the copy is owned by the account `zat`
logs in as, which both organizes them and preserves them if the original
host later leaves the org).
* No separate login: Meet uses the same Google OAuth credentials as Drive.
* The Meet scope is **opt-in**: `zat` requests
`https://www.googleapis.com/auth/meetings.space.readonly` only when `zat.yml`
contains at least one `meet:` directive, so Drive/Zoom-only users are never
prompted for Meet permissions.
* **When you first add a `meet:` directive**, the requested scope changes, so
delete `google.creds.json` and re-run the Google login once to grant it.
* Zoom is optional. A Meet-only install needs only the Google credentials —
if `zoom.config.json` is absent, `zat` logs that Zoom archival is disabled
and continues with Meet.
* Map a meeting in `zat.yml` with a `meet:` key holding the meeting code (the
`abc-defg-hij` part of a `meet.google.com/abc-defg-hij` link). A directive
may set `zoom:`, `meet:`, or both:

```yaml
- name: UI Weekly
google: DpB3XhhzV87LfEeLrM-nCopTtHDWxqVGH
meet: abc-defg-hij
slack: C0123456
```

* The `-t` filter accepts `recording` and `transcript` for Meet artifacts.
* **Scope:** `zat` only sees Meet conferences for the single signed-in account.
Cross-host (org-wide) archival via domain-wide delegation is designed for but
not yet implemented.
* [Optional] Obtain Slack credentials
* [Create an App](https://api.slack.com/apps?new_app=1)
* Add Permissions > Scopes > Bot Token Scopes > Add An Oauth Scope granting: `channels:read`, `chat:write`, `chat:write.public`
Expand Down
1 change: 0 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191025021431-6c3a3bfe00ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e h1:9vRrk9YW2BTzLP0VCB9ZDjU4cPqkg+IDWL7XgxA1yxQ=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
88 changes: 70 additions & 18 deletions google/google.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package google

import (
"context"
"errors"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"sync"

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
Expand All @@ -22,11 +24,21 @@ type Client struct {
logger *log.Logger
httpClient *http.Client

config *oauth2.Config
config *oauth2.Config
// credsMu guards credentials, which is read by background archival
// (via TokenSource/Service) while the OAuth handler writes it.
credsMu sync.RWMutex
credentials *oauth2.Token
cm *credentialsManager
}

// getCreds returns the current credentials under a read lock.
func (c *Client) getCreds() *oauth2.Token {
c.credsMu.RLock()
defer c.credsMu.RUnlock()
return c.credentials
}

type ClientOption func(*Client)

func CustomHTTPClientOption(httpClient *http.Client) ClientOption {
Expand All @@ -35,6 +47,18 @@ func CustomHTTPClientOption(httpClient *http.Client) ClientOption {
}
}

// MeetScope is the read-only Google Meet scope needed to discover conference
// recordings and transcripts.
const MeetScope = "https://www.googleapis.com/auth/meetings.space.readonly"

// WithMeetScope requests the Google Meet read scope in addition to Drive. It is
// opt-in so Drive/Zoom-only users aren't prompted for Meet permissions.
func WithMeetScope() ClientOption {
return func(c *Client) {
c.config.Scopes = append(c.config.Scopes, MeetScope)
}
}

// Config is a google-defined client_credentials.json format.
// Duplicated from golang.org/x/oauth2/google since it is not exported.
type Config struct {
Expand All @@ -61,6 +85,7 @@ func NewClientFromReader(logger *log.Logger, r io.Reader, options ...ClientOptio
return nil, err
}
// https://developers.google.com/identity/protocols/googlescopes#drivev3
// Drive scope for archival; the Meet scope is opt-in via WithMeetScope.
config, err := google.ConfigFromJSON(b, drive.DriveScope)
if err != nil {
return nil, err
Expand All @@ -82,34 +107,37 @@ func NewClient(logger *log.Logger, config *oauth2.Config, options ...ClientOptio
}

func (c *Client) updateCreds(token *oauth2.Token) {
c.credsMu.Lock()
defer c.credsMu.Unlock()
c.credentials = token
if token != nil && c.cm != nil {
// saveCreds reads c.credentials, which we hold the write lock over.
if err := c.cm.saveCreds(c); err != nil {
c.logger.Println("failed to save creds:", err)
}
}
}

func (c *Client) HasCreds() bool {
if c.credentials == nil {
creds := c.getCreds()
if creds == nil {
return false
}

valid := c.credentials.Valid()
if creds.Valid() {
return true
}

if !valid {
c.logger.Println("Google credentials not valid, updating token")
src := c.config.TokenSource(context.TODO(), c.credentials)
newToken, err := src.Token() // this actually goes and renews the tokens
if err != nil {
c.logger.Printf("error updating google token %s", err)
return false
}
if newToken.AccessToken != c.credentials.AccessToken {
c.updateCreds(newToken)
c.credentials = newToken
c.logger.Println("Google credentials updated and saved to disk")
}
c.logger.Println("Google credentials not valid, updating token")
src := c.config.TokenSource(context.TODO(), creds)
newToken, err := src.Token() // this actually goes and renews the tokens
if err != nil {
c.logger.Printf("error updating google token %s", err)
return false
}
if newToken.AccessToken != creds.AccessToken {
c.updateCreds(newToken)
c.logger.Println("Google credentials updated and saved to disk")
}
return true
}
Expand All @@ -122,7 +150,7 @@ func (c *Client) OauthRedirect(w http.ResponseWriter, r *http.Request) {
// TODO: use / validate state token
func (c *Client) OauthHandler() func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if r.FormValue("refresh") != "" || !c.credentials.Valid() {
if r.FormValue("refresh") != "" || !c.getCreds().Valid() {
c.updateCreds(nil)
}

Expand All @@ -147,8 +175,32 @@ func (c *Client) OauthHandler() func(w http.ResponseWriter, r *http.Request) {
}
}

// lazyTokenSource reads the client's credentials fresh on every Token() call,
// so a source handed out before the OAuth web login still works once creds
// arrive (and after they're refreshed). It also fails cleanly - rather than
// returning a source stuck on a nil token - when creds are not yet present.
type lazyTokenSource struct {
c *Client
ctx context.Context
}

func (l lazyTokenSource) Token() (*oauth2.Token, error) {
creds := l.c.getCreds()
if creds == nil {
return nil, errors.New("google: no credentials yet, login required")
}
return l.c.config.TokenSource(l.ctx, creds).Token()
}

// TokenSource exposes an OAuth token source so other Google APIs (e.g. Meet)
// can authenticate with the same credentials. The source resolves credentials
// lazily, so it remains valid across a later web login or token refresh.
func (c *Client) TokenSource(ctx context.Context) oauth2.TokenSource {
return lazyTokenSource{c: c, ctx: ctx}
}
Comment thread
pmoust marked this conversation as resolved.

func (c *Client) Service(ctx context.Context) (*drive.Service, error) {
return drive.NewService(ctx, option.WithTokenSource(c.config.TokenSource(ctx, c.credentials)))
return drive.NewService(ctx, option.WithTokenSource(c.config.TokenSource(ctx, c.getCreds())))
}

func (c *Client) ListFiles(ctx context.Context, q string, pageToken string) (*drive.FileList, error) {
Expand Down
80 changes: 80 additions & 0 deletions google/google_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package google

import (
"bytes"
"context"
"encoding/json"
"log"
"testing"
"time"

"golang.org/x/oauth2"
)

func TestNewClientFromReader(t *testing.T) {
Expand Down Expand Up @@ -77,3 +81,79 @@ func TestNewClientFromReader(t *testing.T) {
})
}
}

func hasScope(scopes []string, want string) bool {
for _, s := range scopes {
if s == want {
return true
}
}
return false
}

// TestDefaultScopesExcludeMeet verifies Meet is not requested by default, so
// Drive/Zoom-only users aren't prompted for Meet permissions.
func TestDefaultScopesExcludeMeet(t *testing.T) {
cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`)
var clog bytes.Buffer
c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg))
if err != nil {
t.Fatal(err)
}
if hasScope(c.config.Scopes, MeetScope) {
t.Errorf("did not expect meet scope by default, got %v", c.config.Scopes)
}
}

// TestWithMeetScope verifies the opt-in option adds the Meet scope.
func TestWithMeetScope(t *testing.T) {
cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`)
var clog bytes.Buffer
c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg), WithMeetScope())
if err != nil {
t.Fatal(err)
}
if !hasScope(c.config.Scopes, MeetScope) {
t.Errorf("expected meet scope with WithMeetScope(), got %v", c.config.Scopes)
}
}

func TestTokenSource(t *testing.T) {
cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`)
var clog bytes.Buffer
c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg))
if err != nil {
t.Fatal(err)
}
c.credentials = &oauth2.Token{AccessToken: "x"}
if c.TokenSource(context.Background()) == nil {
t.Error("expected non-nil token source")
}
}

// TestTokenSourceLazy verifies a source handed out before login (no creds)
// errors cleanly rather than panicking, and then resolves credentials that
// arrive afterward - the web-login-first flow.
func TestTokenSourceLazy(t *testing.T) {
cfg := []byte(`{"web":{"client_id":"id","client_secret":"secret","redirect_uris":["http://r"],"auth_uri":"http://a","token_uri":"http://t"}}`)
var clog bytes.Buffer
c, err := NewClientFromReader(log.New(&clog, "", 0), bytes.NewReader(cfg))
if err != nil {
t.Fatal(err)
}

ts := c.TokenSource(context.Background()) // built before any creds exist
if _, err := ts.Token(); err == nil {
t.Error("expected error from token source before credentials exist")
}

// credentials arrive later (e.g. via the OAuth web callback)
c.credentials = &oauth2.Token{AccessToken: "live-token", Expiry: time.Now().Add(time.Hour)}
tok, err := ts.Token()
if err != nil {
t.Fatalf("expected token after creds arrive, got error: %v", err)
}
if tok.AccessToken != "live-token" {
t.Errorf("expected live-token, got %q", tok.AccessToken)
}
}
Loading