detached fad97fd

Merge pull request 'feat(session): add passkey list and remove RPCs' (#7) from feat/passkeys into main

Reviewed-on: https://git.bnema.dev/bnema/ermite-api/pulls/7
commit
fad97fd3242a99bbef86c556a6768f5f3da1129f
author
bnema · · 2026-10-12 15:36 UTC
merge of
97a9365 + 19d3b27 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
3 files · +420 -19
M gen/ermite/ermiteconnect/session.connect.go +70 -0
@@ -41,6 +41,12 @@ const (
4141 // SessionServiceSignOutEverywhereProcedure is the fully-qualified name of the SessionService's
4242 // SignOutEverywhere RPC.
4343 SessionServiceSignOutEverywhereProcedure = "/ermite.SessionService/SignOutEverywhere"
44+ // SessionServiceListPasskeysProcedure is the fully-qualified name of the SessionService's
45+ // ListPasskeys RPC.
46+ SessionServiceListPasskeysProcedure = "/ermite.SessionService/ListPasskeys"
47+ // SessionServiceRemovePasskeyProcedure is the fully-qualified name of the SessionService's
48+ // RemovePasskey RPC.
49+ SessionServiceRemovePasskeyProcedure = "/ermite.SessionService/RemovePasskey"
4450 )
4551
4652 // SessionServiceClient is a client for the ermite.SessionService service.
@@ -56,6 +62,14 @@ type SessionServiceClient interface {
5662 // other devices. Pending login links are not affected. Tokens are not
5763 // affected either: revoke them with TokenService.
5864 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)
65+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
66+ // with failed_precondition when the server has passkeys disabled.
67+ ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error)
68+ // RemovePasskey removes one passkey. It also signs out every browser
69+ // session, on every device, like SignOutEverywhere. Fails with not_found
70+ // for an unknown id, invalid_argument for an id that is not base64url, and
71+ // failed_precondition when the server has passkeys disabled.
72+ RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error)
5973 }
6074
6175 // NewSessionServiceClient constructs a client for the ermite.SessionService service. By default, it
@@ -81,6 +95,18 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts
8195 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),
8296 connect.WithClientOptions(opts...),
8397 ),
98+ listPasskeys: connect.NewClient[ermite.ListPasskeysRequest, ermite.ListPasskeysResponse](
99+ httpClient,
100+ baseURL+SessionServiceListPasskeysProcedure,
101+ connect.WithSchema(sessionServiceMethods.ByName("ListPasskeys")),
102+ connect.WithClientOptions(opts...),
103+ ),
104+ removePasskey: connect.NewClient[ermite.RemovePasskeyRequest, ermite.RemovePasskeyResponse](
105+ httpClient,
106+ baseURL+SessionServiceRemovePasskeyProcedure,
107+ connect.WithSchema(sessionServiceMethods.ByName("RemovePasskey")),
108+ connect.WithClientOptions(opts...),
109+ ),
84110 }
85111 }
86112
@@ -88,6 +114,8 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts
88114 type sessionServiceClient struct {
89115 createLoginLink *connect.Client[ermite.CreateLoginLinkRequest, ermite.CreateLoginLinkResponse]
90116 signOutEverywhere *connect.Client[ermite.SignOutEverywhereRequest, ermite.SignOutEverywhereResponse]
117+ listPasskeys *connect.Client[ermite.ListPasskeysRequest, ermite.ListPasskeysResponse]
118+ removePasskey *connect.Client[ermite.RemovePasskeyRequest, ermite.RemovePasskeyResponse]
91119 }
92120
93121 // CreateLoginLink calls ermite.SessionService.CreateLoginLink.
@@ -100,6 +128,16 @@ func (c *sessionServiceClient) SignOutEverywhere(ctx context.Context, req *conne
100128 return c.signOutEverywhere.CallUnary(ctx, req)
101129 }
102130
131+// ListPasskeys calls ermite.SessionService.ListPasskeys.
132+func (c *sessionServiceClient) ListPasskeys(ctx context.Context, req *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
133+ return c.listPasskeys.CallUnary(ctx, req)
134+}
135+
136+// RemovePasskey calls ermite.SessionService.RemovePasskey.
137+func (c *sessionServiceClient) RemovePasskey(ctx context.Context, req *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
138+ return c.removePasskey.CallUnary(ctx, req)
139+}
140+
103141 // SessionServiceHandler is an implementation of the ermite.SessionService service.
104142 type SessionServiceHandler interface {
105143 // CreateLoginLink creates a one-time link that signs the owner's browser
@@ -113,6 +151,14 @@ type SessionServiceHandler interface {
113151 // other devices. Pending login links are not affected. Tokens are not
114152 // affected either: revoke them with TokenService.
115153 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)
154+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
155+ // with failed_precondition when the server has passkeys disabled.
156+ ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error)
157+ // RemovePasskey removes one passkey. It also signs out every browser
158+ // session, on every device, like SignOutEverywhere. Fails with not_found
159+ // for an unknown id, invalid_argument for an id that is not base64url, and
160+ // failed_precondition when the server has passkeys disabled.
161+ RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error)
116162 }
117163
118164 // NewSessionServiceHandler builds an HTTP handler from the service implementation. It returns the
@@ -134,12 +180,28 @@ func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.Handler
134180 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),
135181 connect.WithHandlerOptions(opts...),
136182 )
183+ sessionServiceListPasskeysHandler := connect.NewUnaryHandler(
184+ SessionServiceListPasskeysProcedure,
185+ svc.ListPasskeys,
186+ connect.WithSchema(sessionServiceMethods.ByName("ListPasskeys")),
187+ connect.WithHandlerOptions(opts...),
188+ )
189+ sessionServiceRemovePasskeyHandler := connect.NewUnaryHandler(
190+ SessionServiceRemovePasskeyProcedure,
191+ svc.RemovePasskey,
192+ connect.WithSchema(sessionServiceMethods.ByName("RemovePasskey")),
193+ connect.WithHandlerOptions(opts...),
194+ )
137195 return "/ermite.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138196 switch r.URL.Path {
139197 case SessionServiceCreateLoginLinkProcedure:
140198 sessionServiceCreateLoginLinkHandler.ServeHTTP(w, r)
141199 case SessionServiceSignOutEverywhereProcedure:
142200 sessionServiceSignOutEverywhereHandler.ServeHTTP(w, r)
201+ case SessionServiceListPasskeysProcedure:
202+ sessionServiceListPasskeysHandler.ServeHTTP(w, r)
203+ case SessionServiceRemovePasskeyProcedure:
204+ sessionServiceRemovePasskeyHandler.ServeHTTP(w, r)
143205 default:
144206 http.NotFound(w, r)
145207 }
@@ -156,3 +218,11 @@ func (UnimplementedSessionServiceHandler) CreateLoginLink(context.Context, *conn
156218 func (UnimplementedSessionServiceHandler) SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error) {
157219 return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.SignOutEverywhere is not implemented"))
158220 }
221+
222+func (UnimplementedSessionServiceHandler) ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
223+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.ListPasskeys is not implemented"))
224+}
225+
226+func (UnimplementedSessionServiceHandler) RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
227+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.RemovePasskey is not implemented"))
228+}
@@ -41,6 +41,12 @@ const (
41 // SessionServiceSignOutEverywhereProcedure is the fully-qualified name of the SessionService's41 // SessionServiceSignOutEverywhereProcedure is the fully-qualified name of the SessionService's
42 // SignOutEverywhere RPC.42 // SignOutEverywhere RPC.
43 SessionServiceSignOutEverywhereProcedure = "/ermite.SessionService/SignOutEverywhere"43 SessionServiceSignOutEverywhereProcedure = "/ermite.SessionService/SignOutEverywhere"
44+ // SessionServiceListPasskeysProcedure is the fully-qualified name of the SessionService's
45+ // ListPasskeys RPC.
46+ SessionServiceListPasskeysProcedure = "/ermite.SessionService/ListPasskeys"
47+ // SessionServiceRemovePasskeyProcedure is the fully-qualified name of the SessionService's
48+ // RemovePasskey RPC.
49+ SessionServiceRemovePasskeyProcedure = "/ermite.SessionService/RemovePasskey"
44 )50 )
45 51
46 // SessionServiceClient is a client for the ermite.SessionService service.52 // SessionServiceClient is a client for the ermite.SessionService service.
@@ -56,6 +62,14 @@ type SessionServiceClient interface {
56 // other devices. Pending login links are not affected. Tokens are not62 // other devices. Pending login links are not affected. Tokens are not
57 // affected either: revoke them with TokenService.63 // affected either: revoke them with TokenService.
58 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)64 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)
65+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
66+ // with failed_precondition when the server has passkeys disabled.
67+ ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error)
68+ // RemovePasskey removes one passkey. It also signs out every browser
69+ // session, on every device, like SignOutEverywhere. Fails with not_found
70+ // for an unknown id, invalid_argument for an id that is not base64url, and
71+ // failed_precondition when the server has passkeys disabled.
72+ RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error)
59 }73 }
60 74
61 // NewSessionServiceClient constructs a client for the ermite.SessionService service. By default, it75 // NewSessionServiceClient constructs a client for the ermite.SessionService service. By default, it
@@ -81,6 +95,18 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts
81 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),95 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),
82 connect.WithClientOptions(opts...),96 connect.WithClientOptions(opts...),
83 ),97 ),
98+ listPasskeys: connect.NewClient[ermite.ListPasskeysRequest, ermite.ListPasskeysResponse](
99+ httpClient,
100+ baseURL+SessionServiceListPasskeysProcedure,
101+ connect.WithSchema(sessionServiceMethods.ByName("ListPasskeys")),
102+ connect.WithClientOptions(opts...),
103+ ),
104+ removePasskey: connect.NewClient[ermite.RemovePasskeyRequest, ermite.RemovePasskeyResponse](
105+ httpClient,
106+ baseURL+SessionServiceRemovePasskeyProcedure,
107+ connect.WithSchema(sessionServiceMethods.ByName("RemovePasskey")),
108+ connect.WithClientOptions(opts...),
109+ ),
84 }110 }
85 }111 }
86 112
@@ -88,6 +114,8 @@ func NewSessionServiceClient(httpClient connect.HTTPClient, baseURL string, opts
88 type sessionServiceClient struct {114 type sessionServiceClient struct {
89 createLoginLink *connect.Client[ermite.CreateLoginLinkRequest, ermite.CreateLoginLinkResponse]115 createLoginLink *connect.Client[ermite.CreateLoginLinkRequest, ermite.CreateLoginLinkResponse]
90 signOutEverywhere *connect.Client[ermite.SignOutEverywhereRequest, ermite.SignOutEverywhereResponse]116 signOutEverywhere *connect.Client[ermite.SignOutEverywhereRequest, ermite.SignOutEverywhereResponse]
117+ listPasskeys *connect.Client[ermite.ListPasskeysRequest, ermite.ListPasskeysResponse]
118+ removePasskey *connect.Client[ermite.RemovePasskeyRequest, ermite.RemovePasskeyResponse]
91 }119 }
92 120
93 // CreateLoginLink calls ermite.SessionService.CreateLoginLink.121 // CreateLoginLink calls ermite.SessionService.CreateLoginLink.
@@ -100,6 +128,16 @@ func (c *sessionServiceClient) SignOutEverywhere(ctx context.Context, req *conne
100 return c.signOutEverywhere.CallUnary(ctx, req)128 return c.signOutEverywhere.CallUnary(ctx, req)
101 }129 }
102 130
131+// ListPasskeys calls ermite.SessionService.ListPasskeys.
132+func (c *sessionServiceClient) ListPasskeys(ctx context.Context, req *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
133+ return c.listPasskeys.CallUnary(ctx, req)
134+}
135+
136+// RemovePasskey calls ermite.SessionService.RemovePasskey.
137+func (c *sessionServiceClient) RemovePasskey(ctx context.Context, req *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
138+ return c.removePasskey.CallUnary(ctx, req)
139+}
140+
103 // SessionServiceHandler is an implementation of the ermite.SessionService service.141 // SessionServiceHandler is an implementation of the ermite.SessionService service.
104 type SessionServiceHandler interface {142 type SessionServiceHandler interface {
105 // CreateLoginLink creates a one-time link that signs the owner's browser143 // CreateLoginLink creates a one-time link that signs the owner's browser
@@ -113,6 +151,14 @@ type SessionServiceHandler interface {
113 // other devices. Pending login links are not affected. Tokens are not151 // other devices. Pending login links are not affected. Tokens are not
114 // affected either: revoke them with TokenService.152 // affected either: revoke them with TokenService.
115 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)153 SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error)
154+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
155+ // with failed_precondition when the server has passkeys disabled.
156+ ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error)
157+ // RemovePasskey removes one passkey. It also signs out every browser
158+ // session, on every device, like SignOutEverywhere. Fails with not_found
159+ // for an unknown id, invalid_argument for an id that is not base64url, and
160+ // failed_precondition when the server has passkeys disabled.
161+ RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error)
116 }162 }
117 163
118 // NewSessionServiceHandler builds an HTTP handler from the service implementation. It returns the164 // NewSessionServiceHandler builds an HTTP handler from the service implementation. It returns the
@@ -134,12 +180,28 @@ func NewSessionServiceHandler(svc SessionServiceHandler, opts ...connect.Handler
134 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),180 connect.WithSchema(sessionServiceMethods.ByName("SignOutEverywhere")),
135 connect.WithHandlerOptions(opts...),181 connect.WithHandlerOptions(opts...),
136 )182 )
183+ sessionServiceListPasskeysHandler := connect.NewUnaryHandler(
184+ SessionServiceListPasskeysProcedure,
185+ svc.ListPasskeys,
186+ connect.WithSchema(sessionServiceMethods.ByName("ListPasskeys")),
187+ connect.WithHandlerOptions(opts...),
188+ )
189+ sessionServiceRemovePasskeyHandler := connect.NewUnaryHandler(
190+ SessionServiceRemovePasskeyProcedure,
191+ svc.RemovePasskey,
192+ connect.WithSchema(sessionServiceMethods.ByName("RemovePasskey")),
193+ connect.WithHandlerOptions(opts...),
194+ )
137 return "/ermite.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {195 return "/ermite.SessionService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 switch r.URL.Path {196 switch r.URL.Path {
139 case SessionServiceCreateLoginLinkProcedure:197 case SessionServiceCreateLoginLinkProcedure:
140 sessionServiceCreateLoginLinkHandler.ServeHTTP(w, r)198 sessionServiceCreateLoginLinkHandler.ServeHTTP(w, r)
141 case SessionServiceSignOutEverywhereProcedure:199 case SessionServiceSignOutEverywhereProcedure:
142 sessionServiceSignOutEverywhereHandler.ServeHTTP(w, r)200 sessionServiceSignOutEverywhereHandler.ServeHTTP(w, r)
201+ case SessionServiceListPasskeysProcedure:
202+ sessionServiceListPasskeysHandler.ServeHTTP(w, r)
203+ case SessionServiceRemovePasskeyProcedure:
204+ sessionServiceRemovePasskeyHandler.ServeHTTP(w, r)
143 default:205 default:
144 http.NotFound(w, r)206 http.NotFound(w, r)
145 }207 }
@@ -156,3 +218,11 @@ func (UnimplementedSessionServiceHandler) CreateLoginLink(context.Context, *conn
156 func (UnimplementedSessionServiceHandler) SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error) {218 func (UnimplementedSessionServiceHandler) SignOutEverywhere(context.Context, *connect.Request[ermite.SignOutEverywhereRequest]) (*connect.Response[ermite.SignOutEverywhereResponse], error) {
157 return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.SignOutEverywhere is not implemented"))219 return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.SignOutEverywhere is not implemented"))
158 }220 }
221+
222+func (UnimplementedSessionServiceHandler) ListPasskeys(context.Context, *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
223+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.ListPasskeys is not implemented"))
224+}
225+
226+func (UnimplementedSessionServiceHandler) RemovePasskey(context.Context, *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
227+ return nil, connect.NewError(connect.CodeUnimplemented, errors.New("ermite.SessionService.RemovePasskey is not implemented"))
228+}
M gen/ermite/session.pb.go +296 -15 generated file: collapsed
M proto/ermite/session.proto +54 -4
@@ -6,9 +6,12 @@ package ermite;
66
77 import "google/protobuf/timestamp.proto";
88
9-// SessionService manages the owner's browser sessions. The browser itself
10-// never calls it: it signs in by opening the link that CreateLoginLink
11-// returns, and signs out by posting to the web's logout route.
9+// SessionService manages the owner's browser sessions and passkeys. The
10+// browser itself never calls it: it signs in by opening the link that
11+// CreateLoginLink returns, or with a passkey on the web's sign-in page, and
12+// signs out by posting to the web's logout route. Registering a passkey and
13+// signing in with one are browser-only ceremonies and are not part of this
14+// API.
1215 //
1316 // Every RPC is owner-only: it needs the root token, or a stored token that
1417 // holds every scope and is not restricted to repositories. Any other caller
@@ -16,7 +19,9 @@ import "google/protobuf/timestamp.proto";
1619 // API accepts bearer tokens only and ignores cookies.
1720 //
1821 // Servers that implement this service list "session" in
19-// GetVersionResponse.features.
22+// GetVersionResponse.features. They also list "passkeys" when passkeys are
23+// available (they need an https public URL with a DNS host name); without it,
24+// ListPasskeys and RemovePasskey fail with failed_precondition.
2025 service SessionService {
2126 // CreateLoginLink creates a one-time link that signs the owner's browser
2227 // in. The link is valid for a short time (about a minute), can be used
@@ -29,6 +34,14 @@ service SessionService {
2934 // other devices. Pending login links are not affected. Tokens are not
3035 // affected either: revoke them with TokenService.
3136 rpc SignOutEverywhere(SignOutEverywhereRequest) returns (SignOutEverywhereResponse);
37+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
38+ // with failed_precondition when the server has passkeys disabled.
39+ rpc ListPasskeys(ListPasskeysRequest) returns (ListPasskeysResponse);
40+ // RemovePasskey removes one passkey. It also signs out every browser
41+ // session, on every device, like SignOutEverywhere. Fails with not_found
42+ // for an unknown id, invalid_argument for an id that is not base64url, and
43+ // failed_precondition when the server has passkeys disabled.
44+ rpc RemovePasskey(RemovePasskeyRequest) returns (RemovePasskeyResponse);
3245 }
3346
3447 // CreateLoginLinkRequest is empty.
@@ -49,3 +62,40 @@ message SignOutEverywhereRequest {}
4962
5063 // SignOutEverywhereResponse is empty.
5164 message SignOutEverywhereResponse {}
65+
66+// Passkey is a WebAuthn credential that can sign the owner in. It never
67+// carries key material.
68+message Passkey {
69+ // Id is the credential id, base64url without padding. Pass it to
70+ // RemovePasskey.
71+ string id = 1;
72+ // Name is the label the owner gave the passkey when registering it.
73+ string name = 2;
74+ // CreatedAt is when the passkey was registered.
75+ google.protobuf.Timestamp created_at = 3;
76+ // LastUsedAt is when the passkey last signed the owner in. It is unset
77+ // when the passkey was never used.
78+ google.protobuf.Timestamp last_used_at = 4;
79+ // Synced is true when the authenticator reports the credential as backed
80+ // up (the backup state flag), for example a passkey synced by a platform or
81+ // a password manager.
82+ bool synced = 5;
83+}
84+
85+// ListPasskeysRequest is empty.
86+message ListPasskeysRequest {}
87+
88+// ListPasskeysResponse holds the passkeys.
89+message ListPasskeysResponse {
90+ // Passkeys are the registered passkeys, oldest first.
91+ repeated Passkey passkeys = 1;
92+}
93+
94+// RemovePasskeyRequest names the passkey to remove.
95+message RemovePasskeyRequest {
96+ // Id is the credential id from Passkey.id.
97+ string id = 1;
98+}
99+
100+// RemovePasskeyResponse is empty.
101+message RemovePasskeyResponse {}
@@ -6,9 +6,12 @@ package ermite;
6 6
7 import "google/protobuf/timestamp.proto";7 import "google/protobuf/timestamp.proto";
8 8
9-// SessionService manages the owner's browser sessions. The browser itself9+// SessionService manages the owner's browser sessions and passkeys. The
10-// never calls it: it signs in by opening the link that CreateLoginLink10+// browser itself never calls it: it signs in by opening the link that
11-// returns, and signs out by posting to the web's logout route.11+// CreateLoginLink returns, or with a passkey on the web's sign-in page, and
12+// signs out by posting to the web's logout route. Registering a passkey and
13+// signing in with one are browser-only ceremonies and are not part of this
14+// API.
12 //15 //
13 // Every RPC is owner-only: it needs the root token, or a stored token that16 // Every RPC is owner-only: it needs the root token, or a stored token that
14 // holds every scope and is not restricted to repositories. Any other caller17 // holds every scope and is not restricted to repositories. Any other caller
@@ -16,7 +19,9 @@ import "google/protobuf/timestamp.proto";
16 // API accepts bearer tokens only and ignores cookies.19 // API accepts bearer tokens only and ignores cookies.
17 //20 //
18 // Servers that implement this service list "session" in21 // Servers that implement this service list "session" in
19-// GetVersionResponse.features.22+// GetVersionResponse.features. They also list "passkeys" when passkeys are
23+// available (they need an https public URL with a DNS host name); without it,
24+// ListPasskeys and RemovePasskey fail with failed_precondition.
20 service SessionService {25 service SessionService {
21 // CreateLoginLink creates a one-time link that signs the owner's browser26 // CreateLoginLink creates a one-time link that signs the owner's browser
22 // in. The link is valid for a short time (about a minute), can be used27 // in. The link is valid for a short time (about a minute), can be used
@@ -29,6 +34,14 @@ service SessionService {
29 // other devices. Pending login links are not affected. Tokens are not34 // other devices. Pending login links are not affected. Tokens are not
30 // affected either: revoke them with TokenService.35 // affected either: revoke them with TokenService.
31 rpc SignOutEverywhere(SignOutEverywhereRequest) returns (SignOutEverywhereResponse);36 rpc SignOutEverywhere(SignOutEverywhereRequest) returns (SignOutEverywhereResponse);
37+ // ListPasskeys lists the owner's registered passkeys, oldest first. Fails
38+ // with failed_precondition when the server has passkeys disabled.
39+ rpc ListPasskeys(ListPasskeysRequest) returns (ListPasskeysResponse);
40+ // RemovePasskey removes one passkey. It also signs out every browser
41+ // session, on every device, like SignOutEverywhere. Fails with not_found
42+ // for an unknown id, invalid_argument for an id that is not base64url, and
43+ // failed_precondition when the server has passkeys disabled.
44+ rpc RemovePasskey(RemovePasskeyRequest) returns (RemovePasskeyResponse);
32 }45 }
33 46
34 // CreateLoginLinkRequest is empty.47 // CreateLoginLinkRequest is empty.
@@ -49,3 +62,40 @@ message SignOutEverywhereRequest {}
49 62
50 // SignOutEverywhereResponse is empty.63 // SignOutEverywhereResponse is empty.
51 message SignOutEverywhereResponse {}64 message SignOutEverywhereResponse {}
65+
66+// Passkey is a WebAuthn credential that can sign the owner in. It never
67+// carries key material.
68+message Passkey {
69+ // Id is the credential id, base64url without padding. Pass it to
70+ // RemovePasskey.
71+ string id = 1;
72+ // Name is the label the owner gave the passkey when registering it.
73+ string name = 2;
74+ // CreatedAt is when the passkey was registered.
75+ google.protobuf.Timestamp created_at = 3;
76+ // LastUsedAt is when the passkey last signed the owner in. It is unset
77+ // when the passkey was never used.
78+ google.protobuf.Timestamp last_used_at = 4;
79+ // Synced is true when the authenticator reports the credential as backed
80+ // up (the backup state flag), for example a passkey synced by a platform or
81+ // a password manager.
82+ bool synced = 5;
83+}
84+
85+// ListPasskeysRequest is empty.
86+message ListPasskeysRequest {}
87+
88+// ListPasskeysResponse holds the passkeys.
89+message ListPasskeysResponse {
90+ // Passkeys are the registered passkeys, oldest first.
91+ repeated Passkey passkeys = 1;
92+}
93+
94+// RemovePasskeyRequest names the passkey to remove.
95+message RemovePasskeyRequest {
96+ // Id is the credential id from Passkey.id.
97+ string id = 1;
98+}
99+
100+// RemovePasskeyResponse is empty.
101+message RemovePasskeyResponse {}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 18

↑↓ moveenter openesc close